Skip to content

Commit 9ae9c1a

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
feat(webapp): floating Ask Trigger chat window
feat(webapp): floating Ask Trigger chat window The Ask Trigger chat can be shown as a draggable, resizable floating window that docks to the viewport edges, as a right-hand side panel, or fullscreen. The default mode is an account preference; switching modes within a session is temporary. Chat history and an in-flight turn survive mode switches and navigation, and reopening the chat mid-turn resumes the active stream where it left off. The layout is built on a new draggable/resizable primitive and dedicated layout components with mode detection. The chat transport now follows the latest `fetch`, `accessToken` and `startSession` options, so a chat kept open across project or environment navigation sends later messages to the current target, and its output stream reads the live fetch on every reconnect. Mono-RevId: 6a3db06b99b204495b6c7f49303e7a3d64930611
1 parent 480105c commit 9ae9c1a

86 files changed

Lines changed: 4630 additions & 442 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`useTriggerChatTransport` now picks up changes to `accessToken`, `startSession` and `fetch` on re-render, so a chat that stays mounted while the surrounding page changes no longer keeps sending to the endpoint captured on first render.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Ask Trigger now opens as a floating window you can drag anywhere and resize, and the chat header lets you switch it to a right-side panel or fullscreen. Choose the position it opens in from your account settings.

‎apps/webapp/app/assets/icons/AISparkleIcon.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export function AISparkleIcon({ className }: { className?: string }) {
77
viewBox="0 0 24 24"
88
fill="none"
99
xmlns="http://www.w3.org/2000/svg"
10+
aria-hidden="true"
1011
>
1112
<path
1213
fillRule="evenodd"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export function ChatFloatingPanel({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
d="M19 5H5C3.89543 5 3 5.89543 3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V7C21 5.89543 20.1046 5 19 5Z"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
strokeLinejoin="round"
16+
/>
17+
<path
18+
d="M17 10H15C14.4477 10 14 10.4477 14 11V15C14 15.5523 14.4477 16 15 16H17C17.5523 16 18 15.5523 18 15V11C18 10.4477 17.5523 10 17 10Z"
19+
fill="currentColor"
20+
/>
21+
</svg>
22+
);
23+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function ChatFullScreen({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
d="M19 5H5C3.89543 5 3 5.89543 3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V7C21 5.89543 20.1046 5 19 5Z"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
strokeLinejoin="round"
16+
/>
17+
<rect x="5" y="7" width="14" height="10" rx="0.5" fill="currentColor" />
18+
</svg>
19+
);
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function ChatRightPanel({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
d="M19 5H5C3.89543 5 3 5.89543 3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V7C21 5.89543 20.1046 5 19 5Z"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
strokeLinejoin="round"
16+
/>
17+
<rect x="15" y="7" width="4" height="10" rx="0.5" fill="currentColor" />
18+
</svg>
19+
);
20+
}

‎apps/webapp/app/components/dashboard-agent/AgentChart.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
55
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
66
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
77
import { Button } from "~/components/primitives/Buttons";
8-
import { AgentSpinner } from "~/components/primitives/Spinner";
8+
import { TextShimmer } from "~/components/primitives/TextShimmer";
99
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
1010
import { useOptionalOrganization } from "~/hooks/useOrganizations";
1111
import { useOptionalProject } from "~/hooks/useProject";
@@ -158,9 +158,8 @@ export function AgentChart({
158158
) : null}
159159
<div className={cn(AGENT_CHART_PLOT_CLASS)}>
160160
{state.status === "loading" ? (
161-
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-dimmed">
162-
<AgentSpinner size={12} />
163-
Running query…
161+
<div className="flex h-full items-center justify-center text-xs text-text-dimmed">
162+
<TextShimmer>Running query…</TextShimmer>
164163
</div>
165164
) : state.status === "error" ? (
166165
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-error">

‎apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Link } from "@remix-run/react";
2-
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
2+
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
33
import { LinkButton } from "~/components/primitives/Buttons";
44
import { textLinkClassName } from "~/components/primitives/TextLink";
55
import { useOrganization } from "~/hooks/useOrganizations";
@@ -26,7 +26,7 @@ export function AgentUpgradeBlock({
2626
{context}
2727
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
2828
<div className="flex items-center gap-1.5">
29-
<AgentMonoLogo size={16} decorative className="shrink-0" />
29+
<AISparkleIcon className="size-4 shrink-0" />
3030
<span className="text-sm font-medium text-text-bright">
3131
Upgrade to unlock {ASK_AGENT_LABEL}
3232
</span>

‎apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button } from "~/components/primitives/Buttons";
22
import { SimpleTooltip } from "~/components/primitives/Tooltip";
3-
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
3+
import { AgentIcon, ASK_AGENT_LABEL } from "./agent-identity";
44
import { requestDashboardAgent, useDashboardAgentAvailable } from "./dashboardAgentOpenRequest";
55

66
// Goes through the open-request bridge rather than the provider context, so it works
@@ -9,14 +9,12 @@ export function AskAgentButton({
99
prompt,
1010
label = ASK_AGENT_LABEL,
1111
iconOnly = false,
12-
variant = "small-menu-item",
1312
className,
1413
fallback = null,
1514
}: {
1615
prompt?: string;
1716
label?: string;
1817
iconOnly?: boolean;
19-
variant?: "small-menu-item" | "secondary/small" | "primary/small";
2018
className?: string;
2119
fallback?: React.ReactNode;
2220
}) {
@@ -26,10 +24,9 @@ export function AskAgentButton({
2624
const button = (
2725
<Button
2826
type="button"
29-
variant={variant}
27+
variant="small-menu-item"
3028
data-action="ask-agent"
3129
LeadingIcon={AgentIcon}
32-
leadingIconClassName={AGENT_ICON_ACCENT_CLASS}
3330
className={className}
3431
aria-label={iconOnly ? label : undefined}
3532
onClick={() => requestDashboardAgent(prompt)}

‎apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx‎

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contr
22
import { useLocation } from "@remix-run/react";
33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import {
5+
collapsibleHandleClassName,
56
ResizableHandle,
67
ResizablePanel,
78
ResizablePanelGroup,
@@ -10,16 +11,16 @@ import { useEnvironment } from "~/hooks/useEnvironment";
1011
import { useOrganization } from "~/hooks/useOrganizations";
1112
import { useProject } from "~/hooks/useProject";
1213
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
14+
import { useUser } from "~/hooks/useUser";
1315
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
1416
import { agentDeepLinkParams, ASK_AI_SHORTCUT, askAiChannelTarget } from "./ask-ai-channels";
1517
import { DashboardAgentPanel } from "./DashboardAgentPanel";
1618
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
1719
import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
1820
import {
1921
agentHiddenContentClassName,
20-
agentTakeoverClassName,
21-
readAgentFullscreen,
22-
writeAgentFullscreen,
22+
FloatingAgentWindow,
23+
useAgentPanelMode,
2324
} from "./panel-layout";
2425
import { nextPendingTurnChatId } from "./pending-turn";
2526
import { nextVisibleChat } from "./unread-counts";
@@ -35,6 +36,10 @@ import {
3536

3637
const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";
3738

39+
// Superseded by the account preference; a stray value here would otherwise pin the mode
40+
// forever if this cleanup effect never ran.
41+
const STALE_MODE_STORAGE_KEYS = ["tdev:dashboard-agent:mode", "tdev:dashboard-agent:fullscreen"];
42+
3843
// Shorter than the poll interval, so a stuck request is dropped before the next tick.
3944
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;
4045

@@ -60,6 +65,8 @@ export function DashboardAgent({
6065
const organization = useOrganization();
6166
const project = useProject();
6267
const environment = useEnvironment();
68+
const user = useUser();
69+
const modePreference = user.dashboardPreferences.chatOpenMode;
6370
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
6471

6572
const [open, setOpen] = useState(false);
@@ -114,14 +121,21 @@ export function DashboardAgent({
114121
setUnreadWakes(initialUnreadWakes);
115122
setUnreadWork(initialUnreadWork);
116123
}, [environment.id, initialUnreadWakes, initialUnreadWork]);
117-
// Read lazily so SSR always renders the side panel.
118-
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
124+
// Every open starts from the account preference; in-chat switches (toggle, drag-to-dock)
125+
// are transient and never write it back.
126+
const { mode, changeMode, resetToPreference, revertFullscreen } = useAgentPanelMode(
127+
modePreference,
128+
open
129+
);
130+
const fullscreen = mode === "fullscreen";
119131

120-
const toggleFullscreen = useCallback(() => {
121-
setFullscreen((current) => {
122-
writeAgentFullscreen(!current);
123-
return !current;
124-
});
132+
// Superseded localStorage keys; harmless to skip if storage is unavailable.
133+
useEffect(() => {
134+
try {
135+
for (const key of STALE_MODE_STORAGE_KEYS) window.localStorage.removeItem(key);
136+
} catch {
137+
/* ignore */
138+
}
125139
}, []);
126140

127141
// Pathname only: filter and search-param changes must keep fullscreen.
@@ -130,11 +144,8 @@ export function DashboardAgent({
130144
useEffect(() => {
131145
if (previousPathname.current === pathname) return;
132146
previousPathname.current = pathname;
133-
setFullscreen((current) => {
134-
if (current) writeAgentFullscreen(false);
135-
return false;
136-
});
137-
}, [pathname]);
147+
revertFullscreen();
148+
}, [pathname, revertFullscreen]);
138149
const [newChatSeq, setNewChatSeq] = useState(0);
139150
const [requestedMessage, setRequestedMessage] = useState<
140151
{ text: string; seq: number } | undefined
@@ -165,13 +176,14 @@ export function DashboardAgent({
165176
setOpen(false);
166177
// Pending requests must be dropped or a stale one re-applies on the next open.
167178
visibleChat.current = null;
168-
setFullscreen(false);
169-
writeAgentFullscreen(false);
179+
// Any transient in-chat mode switch applied only until close; the next open
180+
// starts from the account preference again.
181+
resetToPreference();
170182
setRequestedMessage(undefined);
171183
setOpenChatRequest(undefined);
172184
setWatchRequest(undefined);
173185
},
174-
[openPanel]
186+
[openPanel, resetToPreference]
175187
);
176188

177189
const openChat = useCallback(
@@ -346,7 +358,10 @@ export function DashboardAgent({
346358
return (
347359
<DashboardAgentProvider value={context}>
348360
{open ? (
349-
// `relative` is the takeover's containing block.
361+
// `relative` is the fullscreen takeover's containing block. The ResizablePanelGroup
362+
// stays mounted across all three modes — only its sizing/handle degenerate outside
363+
// rightPanel — so `FloatingAgentWindow` (and the chat panel inside it) sits at the
364+
// same tree position in every mode and a mode switch never remounts it.
350365
<div className="relative h-full min-h-0">
351366
<ResizablePanelGroup
352367
orientation="horizontal"
@@ -358,25 +373,42 @@ export function DashboardAgent({
358373
</ResizablePanel>
359374
<ResizableHandle
360375
id="dashboard-agent-handle"
361-
className={fullscreen ? "invisible" : undefined}
376+
size={mode === "rightPanel" ? "3px" : "0px"}
377+
className={collapsibleHandleClassName(mode === "rightPanel")}
362378
/>
363-
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
364-
<div className={agentTakeoverClassName(fullscreen)}>
365-
<DashboardAgentPanel
366-
onClose={() => setPanelOpen(false)}
367-
requestedMessage={requestedMessage}
368-
openChatRequest={openChatRequest}
369-
watchRequest={watchRequest}
370-
newChatSeq={newChatSeq}
371-
promotedPrompt={promotedPrompt}
372-
onChatRead={markChatRead}
373-
// The panel's own count, off the chat list it has already marked read.
374-
onUnreadWorkChange={setUnreadWork}
375-
onTurnActivityChange={handleTurnActivityChange}
376-
isFullscreen={fullscreen}
377-
onToggleFullscreen={toggleFullscreen}
378-
/>
379-
</div>
379+
<ResizablePanel
380+
id="dashboard-agent-panel"
381+
default="380px"
382+
min="320px"
383+
max="720px"
384+
collapsible
385+
collapsed={mode !== "rightPanel"}
386+
collapsedSize="0px"
387+
// Non-rightPanel modes render through position:fixed/absolute, which must
388+
// escape this panel's own clipping box to avoid being cut to its 0px width.
389+
// Tailwind v4's important modifier is a trailing `!`, not a leading one.
390+
className={mode === "rightPanel" ? undefined : "overflow-visible!"}
391+
>
392+
<FloatingAgentWindow mode={mode} onRequestModeChange={changeMode}>
393+
{({ dragHandleProps, dragHandleClassName }) => (
394+
<DashboardAgentPanel
395+
onClose={() => setPanelOpen(false)}
396+
requestedMessage={requestedMessage}
397+
openChatRequest={openChatRequest}
398+
watchRequest={watchRequest}
399+
newChatSeq={newChatSeq}
400+
promotedPrompt={promotedPrompt}
401+
onChatRead={markChatRead}
402+
// The panel's own count, off the chat list it has already marked read.
403+
onUnreadWorkChange={setUnreadWork}
404+
onTurnActivityChange={handleTurnActivityChange}
405+
mode={mode}
406+
onModeChange={changeMode}
407+
dragHandleProps={dragHandleProps}
408+
dragHandleClassName={dragHandleClassName}
409+
/>
410+
)}
411+
</FloatingAgentWindow>
380412
</ResizablePanel>
381413
</ResizablePanelGroup>
382414
</div>

0 commit comments

Comments
 (0)