Skip to content

Commit 1ca17ed

Browse files
committed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-watch
2 parents 1572b88 + 9d48f42 commit 1ca17ed

14 files changed

Lines changed: 600 additions & 108 deletions
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+
A new chat with the agent now opens with a bigger prompt field and one-click suggestions for the page you're on. You can also expand the agent to fill the screen and collapse it back to the side panel, keeping your conversation.

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DashboardAgentPanel } from "./DashboardAgentPanel";
1313
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
1414
import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
1515
import {
16+
<<<<<<< HEAD
1617
showWatchWakesSummaryToast,
1718
showWatchWakeToast,
1819
WAKE_TOAST_MAX_INDIVIDUAL,
@@ -22,6 +23,13 @@ import {
2223
// How often the closed panel asks whether a watch woke a chat. A wake is worth
2324
// noticing within a minute, and the count is one indexed query.
2425
const UNREAD_POLL_INTERVAL_MS = 60_000;
26+
=======
27+
agentHiddenContentClassName,
28+
agentTakeoverClassName,
29+
readAgentFullscreen,
30+
writeAgentFullscreen,
31+
} from "./panel-layout";
32+
>>>>>>> origin/feat/dashboard-agent-flows
2533

2634
/**
2735
* Mounts the dashboard agent in the env layout. Renders the page content
@@ -51,12 +59,25 @@ export function DashboardAgent({
5159
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
5260

5361
const [open, setOpen] = useState(false);
62+
<<<<<<< HEAD
5463
const [unreadWakes, setUnreadWakes] = useState(0);
5564
// Wakes already toasted this session. Session-scoped on purpose: a wake that
5665
// arrived overnight deserves the toast on the first poll after a reload, but a
5766
// wake the user has already been shown (and maybe dismissed) must not come
5867
// back every 60s while the chat stays unread.
5968
const toastedWakes = useRef(new Set<string>());
69+
=======
70+
// The side panel is the default; someone who last worked fullscreen gets
71+
// fullscreen back. Read lazily so SSR always renders the side panel.
72+
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
73+
74+
const toggleFullscreen = useCallback(() => {
75+
setFullscreen((current) => {
76+
writeAgentFullscreen(!current);
77+
return !current;
78+
});
79+
}, []);
80+
>>>>>>> origin/feat/dashboard-agent-flows
6081
// A request from `openWith`, handed to the panel. `seq` makes repeat requests
6182
// with the same text distinct, so the panel can tell them apart.
6283
const [requestedMessage, setRequestedMessage] = useState<
@@ -197,6 +218,7 @@ export function DashboardAgent({
197218
return (
198219
<DashboardAgentProvider value={context}>
199220
{open ? (
221+
<<<<<<< HEAD
200222
<ResizablePanelGroup
201223
orientation="horizontal"
202224
autosaveId="dashboard-agent-split"
@@ -214,9 +236,39 @@ export function DashboardAgent({
214236
watchRequest={watchRequest}
215237
promotedPrompt={promotedPrompt}
216238
onChatRead={markChatRead}
239+
=======
240+
// `relative` is the takeover's containing block: in fullscreen the panel
241+
// is pinned over this box — everything right of the side nav — while the
242+
// split, the page content and the panel itself all stay mounted. Toggling
243+
// fullscreen is a class change, nothing more, so the open chat's
244+
// transport and transcript survive it. See `panel-layout.tsx`.
245+
<div className="relative h-full min-h-0">
246+
<ResizablePanelGroup
247+
orientation="horizontal"
248+
autosaveId="dashboard-agent-split"
249+
className="h-full min-h-0"
250+
>
251+
<ResizablePanel id="dashboard-content" min="320px">
252+
<div className={agentHiddenContentClassName(fullscreen)}>{children}</div>
253+
</ResizablePanel>
254+
<ResizableHandle
255+
id="dashboard-agent-handle"
256+
className={fullscreen ? "invisible" : undefined}
257+
>>>>>>> origin/feat/dashboard-agent-flows
217258
/>
218-
</ResizablePanel>
219-
</ResizablePanelGroup>
259+
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
260+
<div className={agentTakeoverClassName(fullscreen)}>
261+
<DashboardAgentPanel
262+
onClose={() => setPanelOpen(false)}
263+
requestedMessage={requestedMessage}
264+
promotedPrompt={promotedPrompt}
265+
isFullscreen={fullscreen}
266+
onToggleFullscreen={toggleFullscreen}
267+
/>
268+
</div>
269+
</ResizablePanel>
270+
</ResizablePanelGroup>
271+
</div>
220272
) : (
221273
<div className="h-full min-h-0 overflow-hidden">{children}</div>
222274
)}

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { useToast } from "~/components/primitives/Toast";
99
import { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate";
1010
import { DashboardAgentComposer } from "./DashboardAgentComposer";
1111
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
12+
import { DashboardAgentHero } from "./DashboardAgentHero";
1213
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
13-
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
1414
import { createTranscriptOrder, orderTranscript } from "./message-order";
1515
import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
1616
import type { AgentPageContext } from "./page-context-types";
@@ -385,7 +385,9 @@ export function DashboardAgentChat({
385385
be sent, so the prompts would flash for a frame before the transcript
386386
replaced them. Gate on that pending send. */}
387387
{messages.length === 0 && !pendingFirstMessage ? (
388-
<DashboardAgentSuggestedPrompts
388+
// The blank state, minus the composer: this chat already has one docked
389+
// below.
390+
<DashboardAgentHero
389391
onSelect={submit}
390392
pageContext={clientData.pageContext}
391393
promoted={promotedPrompt}

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
55

6+
/**
7+
* - `docked` — the composer at the bottom of the panel, one line tall at rest.
8+
* - `hero` — the composer in the blank-state hero: a large field with the send
9+
* button (and the context chip) on a row inside it.
10+
*/
11+
export type DashboardAgentComposerLayout = "docked" | "hero";
12+
613
export function DashboardAgentComposer({
714
value,
815
onChange,
@@ -11,6 +18,8 @@ export function DashboardAgentComposer({
1118
isStreaming,
1219
focusKey,
1320
context,
21+
layout = "docked",
22+
autoFocus = true,
1423
}: {
1524
value: string;
1625
onChange: (value: string) => void;
@@ -23,29 +32,73 @@ export function DashboardAgentComposer({
2332
// The context chip. It describes the message about to be sent, so it belongs
2433
// in the composer's footer rather than at the top of the panel.
2534
context?: React.ReactNode;
35+
// Where this composer is mounted. `docked` (the default) is the panel footer
36+
// and renders exactly as it always has.
37+
layout?: DashboardAgentComposerLayout;
38+
// Off only where a composer isn't the thing the user came for — the storybook
39+
// gallery renders several at once and must not steal the page's focus.
40+
autoFocus?: boolean;
2641
}) {
2742
const ref = useRef<HTMLTextAreaElement>(null);
2843

2944
useEffect(() => {
3045
const el = ref.current;
31-
if (!el) return;
46+
if (!el || !autoFocus) return;
3247
el.focus();
3348
// Caret after any prefilled text, so typing continues the sentence.
3449
el.setSelectionRange(el.value.length, el.value.length);
35-
}, [focusKey]);
50+
}, [focusKey, autoFocus]);
51+
52+
const isHero = layout === "hero";
53+
54+
const sendButton = isStreaming ? (
55+
// Grey, not red: stopping is a normal thing to do mid-answer, not a
56+
// destructive action.
57+
<Button
58+
variant="minimal/small"
59+
className="aspect-square h-6 p-1"
60+
aria-label="Stop generating"
61+
tooltip="Stop generating"
62+
onClick={onStop}
63+
LeadingIcon={<StopIcon className="size-4 text-text-dimmed" />}
64+
/>
65+
) : (
66+
<Button
67+
variant="primary/small"
68+
className="aspect-square h-6 p-1"
69+
aria-label="Send"
70+
tooltip="Send"
71+
onClick={onSubmit}
72+
disabled={!value.trim()}
73+
LeadingIcon={<ArrowUpIcon className="size-4" />}
74+
/>
75+
);
3676

3777
return (
3878
// No top border: the transcript scrolls behind the footer, which is what the
3979
// gradient in `ChatTranscript`'s scroller edge is for.
40-
<div className="flex shrink-0 flex-col gap-1.5 bg-background-bright px-3 pb-3 pt-1">
41-
{context}
42-
<div className="rounded-md border border-border-bright bg-background-bright p-1 transition focus-within:border-border-brighter">
43-
<div className="flex items-end gap-1">
44-
{/* One text line tall at rest (matches the button height), grows with
45-
content up to the cap. rows={1} + field-sizing-content do the work. */}
80+
<div
81+
className={cn(
82+
"flex shrink-0 flex-col gap-1.5",
83+
isHero ? "w-full" : "bg-background-bright px-3 pb-3 pt-1"
84+
)}
85+
>
86+
{/* In the hero the context chip rides on the field's own bottom row, next
87+
to the send button, so the field reads as one block. */}
88+
{isHero ? null : context}
89+
<div
90+
className={cn(
91+
"border border-border-bright bg-background-bright transition focus-within:border-border-brighter",
92+
isHero ? "rounded-lg p-2" : "rounded-md p-1"
93+
)}
94+
>
95+
<div className={isHero ? "flex flex-col gap-1.5" : "flex items-end gap-1"}>
96+
{/* Docked: one text line tall at rest (matches the button height),
97+
growing with content up to the cap. Hero: three lines at rest, the
98+
same growth. rows + field-sizing-content do the work. */}
4699
<textarea
47100
ref={ref}
48-
rows={1}
101+
rows={isHero ? 3 : 1}
49102
value={value}
50103
onChange={(e) => onChange(e.target.value)}
51104
onKeyDown={(e) => {
@@ -57,30 +110,17 @@ export function DashboardAgentComposer({
57110
placeholder="Type a message…"
58111
aria-label="Message the dashboard agent"
59112
className={cn(
60-
"max-h-[40vh] flex-1 resize-none border-0 bg-transparent px-1.5 py-0.5 text-sm leading-6 text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0"
113+
"max-h-[40vh] flex-1 resize-none border-0 bg-transparent px-1.5 py-0.5 text-sm leading-6 text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0",
114+
isHero && "w-full"
61115
)}
62116
/>
63-
{isStreaming ? (
64-
// Grey, not red: stopping is a normal thing to do mid-answer, not a
65-
// destructive action.
66-
<Button
67-
variant="minimal/small"
68-
className="aspect-square h-6 p-1"
69-
aria-label="Stop generating"
70-
tooltip="Stop generating"
71-
onClick={onStop}
72-
LeadingIcon={<StopIcon className="size-4 text-text-dimmed" />}
73-
/>
117+
{isHero ? (
118+
<div className="flex min-w-0 items-center justify-between gap-2">
119+
{context ?? <span />}
120+
{sendButton}
121+
</div>
74122
) : (
75-
<Button
76-
variant="primary/small"
77-
className="aspect-square h-6 p-1"
78-
aria-label="Send"
79-
tooltip="Send"
80-
onClick={onSubmit}
81-
disabled={!value.trim()}
82-
LeadingIcon={<ArrowUpIcon className="size-4" />}
83-
/>
123+
sendButton
84124
)}
85125
</div>
86126
</div>

apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
22
import { useCallback, useState } from "react";
33
import { DashboardAgentComposer } from "./DashboardAgentComposer";
44
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
5-
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
5+
import { DashboardAgentHero } from "./DashboardAgentHero";
66
import type { AgentPageContext } from "./page-context-types";
77

88
/**
9-
* The new-chat "draft" state: suggested prompts + composer with no transport
10-
* mounted and no chat id yet. The chat id is server-owned, so the first send
11-
* goes to the panel's `create` call, which generates the id and returns it;
12-
* only then does the real `DashboardAgentChat` mount. The client never invents
13-
* a chat id.
9+
* The new-chat "draft" state: the blank-state hero — title, subtitle, the field
10+
* you type in and the suggested prompts — with no transport mounted and no chat
11+
* id yet. The chat id is server-owned, so the first send goes to the panel's
12+
* `create` call, which generates the id and returns it; only then does the real
13+
* `DashboardAgentChat` mount. The client never invents a chat id.
14+
*
15+
* The composer lives inside the hero here and docks to the bottom of the panel
16+
* once a chat exists — the same component either way, so nothing about the field
17+
* (focus, caret, keybindings) changes across that switch.
1418
*/
1519
export function DashboardAgentDraft({
1620
onSubmit,
@@ -45,6 +49,7 @@ export function DashboardAgentDraft({
4549
);
4650

4751
return (
52+
<<<<<<< HEAD
4853
<>
4954
<DashboardAgentSuggestedPrompts
5055
onSelect={submit}
@@ -67,5 +72,29 @@ export function DashboardAgentDraft({
6772
}
6873
/>
6974
</>
75+
=======
76+
<DashboardAgentHero
77+
onSelect={submit}
78+
pageContext={pageContext}
79+
promoted={promotedPrompt}
80+
composer={
81+
<DashboardAgentComposer
82+
layout="hero"
83+
value={input}
84+
onChange={setInput}
85+
onSubmit={() => submit(input)}
86+
onStop={() => {}}
87+
isStreaming={false}
88+
context={
89+
<DashboardAgentContextBanner
90+
projectSlug={projectSlug}
91+
environmentSlug={environmentSlug}
92+
currentPage={currentPage}
93+
/>
94+
}
95+
/>
96+
}
97+
/>
98+
>>>>>>> origin/feat/dashboard-agent-flows
7099
);
71100
}

apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
12
import { useState } from "react";
23
import { CrossIcon } from "~/assets/icons/CrossIcon";
34
import { PlusIcon } from "~/assets/icons/PlusIcon";
@@ -34,6 +35,8 @@ export function DashboardAgentHeader({
3435
onOpenHistory,
3536
onSelectChat,
3637
onDeleteChat,
38+
onToggleFullscreen,
39+
isFullscreen,
3740
onClose,
3841
}: {
3942
// The active chat's title (the panel owns resolving it). Titles are written by
@@ -50,6 +53,9 @@ export function DashboardAgentHeader({
5053
onOpenHistory: () => void;
5154
onSelectChat: (chatId: string) => void;
5255
onDeleteChat: (chatId: string) => void;
56+
/** Swap between the side panel and the fullscreen takeover. */
57+
onToggleFullscreen: () => void;
58+
isFullscreen: boolean;
5359
onClose: () => void;
5460
}) {
5561
const [isHistoryOpen, setHistoryOpen] = useState(false);
@@ -104,6 +110,20 @@ export function DashboardAgentHeader({
104110
LeadingIcon={<PlusIcon className="size-4 text-text-dimmed" />}
105111
/>
106112
)}
113+
<Button
114+
variant="minimal/small"
115+
className="aspect-square h-6 p-1"
116+
aria-label={isFullscreen ? "Collapse into the side panel" : "Expand"}
117+
tooltip={isFullscreen ? "Collapse into the side panel" : "Expand"}
118+
onClick={onToggleFullscreen}
119+
LeadingIcon={
120+
isFullscreen ? (
121+
<ArrowsPointingInIcon className="size-4 text-text-dimmed" />
122+
) : (
123+
<ArrowsPointingOutIcon className="size-4 text-text-dimmed" />
124+
)
125+
}
126+
/>
107127
{/* Esc is handled by the panel (only while focus is inside it), so the
108128
key is shown here rather than registered as a global shortcut. */}
109129
<Button

0 commit comments

Comments
 (0)