Skip to content

Commit 3a0f4b4

Browse files
committed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-watch
2 parents cf87e0b + e6164fc commit 3a0f4b4

8 files changed

Lines changed: 100 additions & 32 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export function DashboardAgent({
7575
}, []);
7676
// A request from `openWith`, handed to the panel. `seq` makes repeat requests
7777
// with the same text distinct, so the panel can tell them apart.
78+
// Bumped by contextual ⌘J while the panel is open; the panel starts a new
79+
// chat when it changes.
80+
const [newChatSeq, setNewChatSeq] = useState(0);
7881
const [requestedMessage, setRequestedMessage] = useState<
7982
{ text: string; seq: number } | undefined
8083
>(undefined);
@@ -186,12 +189,18 @@ export function DashboardAgent({
186189
[actionPath]
187190
);
188191

189-
// ⌘J toggles the panel. Opening mounts the composer, which focuses itself, so
190-
// the shortcut lands you in the text field. Enabled inside inputs too, so the
191-
// same keystroke closes the panel while you're typing in it.
192+
// ⌘J is contextual: closed → open the panel (the composer focuses itself, so
193+
// the keystroke lands you in the text field); open → start a new chat.
194+
// Closing is Esc or the header's ×, never ⌘J.
192195
useShortcutKeys({
193196
shortcut: TOGGLE_PANEL_SHORTCUT,
194-
action: () => setPanelOpen(!open),
197+
action: () => {
198+
if (!open) {
199+
setPanelOpen(true);
200+
} else {
201+
setNewChatSeq((seq) => seq + 1);
202+
}
203+
},
195204
disabled: !hasAccess,
196205
enabledOnInputElements: true,
197206
});
@@ -238,6 +247,7 @@ export function DashboardAgent({
238247
requestedMessage={requestedMessage}
239248
openChatRequest={openChatRequest}
240249
watchRequest={watchRequest}
250+
newChatSeq={newChatSeq}
241251
promotedPrompt={promotedPrompt}
242252
onChatRead={markChatRead}
243253
isFullscreen={fullscreen}

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function DashboardAgentComposer({
2020
context,
2121
layout = "docked",
2222
autoFocus = true,
23+
placeholderSuggestion,
2324
}: {
2425
value: string;
2526
onChange: (value: string) => void;
@@ -38,6 +39,12 @@ export function DashboardAgentComposer({
3839
// Off only where a composer isn't the thing the user came for — the storybook
3940
// gallery renders several at once and must not steal the page's focus.
4041
autoFocus?: boolean;
42+
/**
43+
* A suggested prompt shown as the placeholder while the field is empty.
44+
* Tab accepts it into the field as editable text — it is never sent on its
45+
* own. Once anything is typed, Tab goes back to being Tab.
46+
*/
47+
placeholderSuggestion?: string;
4148
}) {
4249
const ref = useRef<HTMLTextAreaElement>(null);
4350

@@ -56,7 +63,7 @@ export function DashboardAgentComposer({
5663
// destructive action.
5764
<Button
5865
variant="minimal/small"
59-
className="aspect-square h-6 p-1"
66+
className="aspect-square h-6 min-w-0 p-1"
6067
aria-label="Stop generating"
6168
tooltip="Stop generating"
6269
onClick={onStop}
@@ -65,7 +72,7 @@ export function DashboardAgentComposer({
6572
) : (
6673
<Button
6774
variant="primary/small"
68-
className="aspect-square h-6 p-1"
75+
className="aspect-square h-6 min-w-0 p-1"
6976
aria-label="Send"
7077
tooltip="Send"
7178
onClick={onSubmit}
@@ -106,8 +113,19 @@ export function DashboardAgentComposer({
106113
e.preventDefault();
107114
onSubmit();
108115
}
116+
// Tab accepts the suggested placeholder into the field (still
117+
// editable, not sent). Only while empty — with text present, Tab
118+
// keeps its normal focus behavior.
119+
if (e.key === "Tab" && !e.shiftKey && placeholderSuggestion && value === "") {
120+
e.preventDefault();
121+
onChange(placeholderSuggestion);
122+
requestAnimationFrame(() => {
123+
const el = ref.current;
124+
el?.setSelectionRange(el.value.length, el.value.length);
125+
});
126+
}
109127
}}
110-
placeholder="Type a message…"
128+
placeholder={placeholderSuggestion ?? "Type a message…"}
111129
aria-label="Message the dashboard agent"
112130
className={cn(
113131
"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",

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
2-
import { useCallback, useState } from "react";
2+
import { useCallback, useMemo, useState } from "react";
33
import { DashboardAgentComposer } from "./DashboardAgentComposer";
44
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
55
import { DashboardAgentHero } from "./DashboardAgentHero";
66
import type { AgentPageContext } from "./page-context-types";
7+
import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
78

89
/**
910
* The new-chat "draft" state: the blank-state hero — title, subtitle, the field
@@ -38,6 +39,22 @@ export function DashboardAgentDraft({
3839
}) {
3940
const [input, setInput] = useState("");
4041

42+
// The top resolved prompt doubles as the field's placeholder (Tab accepts it
43+
// as editable text). Same resolution the hero's buttons use, so the
44+
// placeholder is always the first button the user sees.
45+
const [dismissedIds] = useState(readDismissedPromptIds);
46+
const placeholderSuggestion = useMemo(
47+
() =>
48+
resolveSuggestedPromptsBySlot(
49+
pageContext ?? { page: { kind: "other", path: "" }, signals: [] },
50+
{
51+
promoted: promotedPrompt,
52+
dismissedIds,
53+
}
54+
)[0]?.prompt.prompt,
55+
[pageContext, promotedPrompt, dismissedIds]
56+
);
57+
4158
const submit = useCallback(
4259
(text: string) => {
4360
const trimmed = text.trim();
@@ -66,6 +83,7 @@ export function DashboardAgentDraft({
6683
onSubmit={() => submit(input)}
6784
onStop={() => {}}
6885
isStreaming={false}
86+
placeholderSuggestion={placeholderSuggestion}
6987
context={
7088
<DashboardAgentContextBanner
7189
projectSlug={projectSlug}

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey";
88
import type { Shortcut } from "~/hooks/useShortcutKeys";
99
import { DashboardAgentHistoryMenu, type DashboardAgentChat } from "./DashboardAgentHistory";
1010

11-
/** New chat. Sits next to the panel's own ⌘J so the pair is easy to remember. */
11+
/**
12+
* New chat — the same ⌘J that opens the panel: contextual, registered ONCE in
13+
* `DashboardAgent` (closed → open, open → new chat). This constant is display
14+
* only; nothing else may register it or the keystroke would fire twice.
15+
*/
1216
export const NEW_CHAT_SHORTCUT: Shortcut = {
13-
modifiers: ["mod", "shift"],
17+
modifiers: ["mod"],
1418
key: "j",
15-
// The composer holds focus while the panel is open, so a shortcut that only
16-
// fires outside inputs would never fire at all.
1719
enabledOnInputElements: true,
1820
};
1921

@@ -104,8 +106,12 @@ export function DashboardAgentHeader({
104106
variant="minimal/small"
105107
className="aspect-square h-6 p-1"
106108
aria-label="New chat"
107-
tooltip="New chat"
108-
shortcut={NEW_CHAT_SHORTCUT}
109+
tooltip={
110+
<span className="flex items-center">
111+
New chat
112+
<ShortcutKey shortcut={NEW_CHAT_SHORTCUT} variant="medium" />
113+
</span>
114+
}
109115
onClick={onNewChat}
110116
LeadingIcon={<PlusIcon className="size-4 text-text-dimmed" />}
111117
/>

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export function DashboardAgentPanel({
8484
onClose,
8585
requestedMessage,
8686
openChatRequest,
87+
newChatSeq,
8788
promotedPrompt,
8889
watchRequest,
8990
onChatRead,
@@ -101,6 +102,9 @@ export function DashboardAgentPanel({
101102
// A specific chat to show, from outside the panel (a wake toast). `seq`
102103
// distinguishes repeat requests for the same chat.
103104
openChatRequest?: { chatId: string; seq: number };
105+
// Bumped by the contextual ⌘J while the panel is open — each change starts a
106+
// new chat.
107+
newChatSeq?: number;
104108
// The product-controlled promoted prompt chip, from the feature flag.
105109
promotedPrompt?: SuggestedPrompt;
106110
// A watch card asked for by a `Watch…` entry. `seq` distinguishes repeats.
@@ -513,6 +517,15 @@ export function DashboardAgentPanel({
513517
[openChat]
514518
);
515519

520+
// Contextual ⌘J: each bump while the panel is open starts a new chat. A ref
521+
// skips the mount-time value so opening the panel never resets a restored chat.
522+
const seenNewChatSeq = useRef(newChatSeq ?? 0);
523+
useEffect(() => {
524+
if (newChatSeq === undefined || newChatSeq === seenNewChatSeq.current) return;
525+
seenNewChatSeq.current = newChatSeq;
526+
newChat();
527+
}, [newChatSeq, newChat]);
528+
516529
const deleteChat = useCallback(
517530
async (id: string) => {
518531
const body = new FormData();

apps/webapp/app/components/dashboard-agent/chat-layout.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
* card (diagnosis, investigation, report, chart), a
2323
* callout, a chip row
2424
* - `ChatProgress` — a spinner and one line of progress
25-
* - `ChatPendingTool` — a tool call still in flight, as a compact pill
25+
* - `ChatPendingTool` — a tool call still in flight: a bare spinner line
2626
* - `ChatToolRow` — a tool-call row, optionally with progress under it
2727
* - `ChatNote` — an inline system / interceptor note
2828
* - `ChatSystemBlock` — a deterministic form/system block (the watch card and
@@ -227,7 +227,7 @@ export function ChatProgress({ children }: { children: React.ReactNode }) {
227227
}
228228

229229
/**
230-
* A tool call still in flight, as a compact pill: a spinner and one short phrase
230+
* A tool call still in flight: a spinner and one short phrase
231231
* saying what the agent is doing.
232232
*
233233
* It replaces the tool row for the whole in-flight phase, so the transcript never
@@ -238,17 +238,12 @@ export function ChatProgress({ children }: { children: React.ReactNode }) {
238238
*/
239239
export function ChatPendingTool({ label }: { label: string }) {
240240
const insetClass = useInsetClass();
241+
// A bare progress line, not a pill: bordered chips read as artifacts that
242+
// stay, while in-flight work is transient — the same register as ChatProgress.
241243
return (
242-
<div className={cn(insetClass, "flex min-w-0")}>
243-
<span
244-
className={cn(
245-
"inline-flex h-6 min-w-0 items-center rounded-full border border-border-bright bg-background-bright px-2.5 text-xs text-text-dimmed",
246-
CHIP_GAP
247-
)}
248-
>
249-
<Spinner className="size-3 shrink-0" />
250-
<span className="truncate">{label}</span>
251-
</span>
244+
<div className={cn(insetClass, "flex min-w-0 items-center text-xs text-text-dimmed", CHIP_GAP)}>
245+
<Spinner className="size-3 shrink-0" />
246+
<span className="truncate">{label}</span>
252247
</div>
253248
);
254249
}

internal-packages/dashboard-agent/src/dashboard-agent.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -972,7 +972,7 @@ describe("buildDashboardAgentTools", () => {
972972
await expect(
973973
callTool("navigate_to", { destination: { kind: "error", fingerprint: "error_1" } })
974974
).resolves.toEqual({
975-
intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/error/error_1" },
975+
intent: { kind: "navigate", target: "trigger://proj_abc/env_abc/error/1" },
976976
});
977977

978978
// A queue name's `/` is percent-encoded, so a task queue round-trips.
@@ -1153,9 +1153,9 @@ describe("buildDashboardAgentTools", () => {
11531153
expect(output.error).toBeUndefined();
11541154
const investigation = output.blocks[0].investigation;
11551155
expect(investigation.hypotheses[0].evidence.map((e: { uri: string }) => e.uri)).toEqual([
1156-
"trigger://proj_abc/env_abc/error/error_c4b4a797397a9c43",
1156+
"trigger://proj_abc/env_abc/error/c4b4a797397a9c43",
11571157
"trigger://proj_abc/env_abc/deployment/20260726.4",
1158-
"trigger://proj_abc/env_abc/error/error_c4b4a797397a9c43",
1158+
"trigger://proj_abc/env_abc/error/c4b4a797397a9c43",
11591159
]);
11601160
expect(investigation.evidence.map((e: { uri: string }) => e.uri)).toEqual([
11611161
"trigger://proj_abc/env_abc/run/run_abc123",
@@ -1349,7 +1349,7 @@ describe("buildDashboardAgentTools", () => {
13491349
// The follow-up that navigates points at the canonical error URI.
13501350
expect(actions[2].intent).toEqual({
13511351
kind: "navigate",
1352-
target: "trigger://proj_abc/env_abc/error/error_c4b4a797397a9c43",
1352+
target: "trigger://proj_abc/env_abc/error/c4b4a797397a9c43",
13531353
});
13541354
});
13551355

internal-packages/dashboard-agent/src/tools.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,10 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
848848
parsed = { ...base, kind: "run", runId: ref };
849849
break;
850850
case "error":
851-
parsed = { ...base, kind: "error", fingerprint: ref };
851+
// The errors API returns friendly ids ("error_<fingerprint>") but the
852+
// canonical URI — and the dashboard error page it resolves to — key on
853+
// the raw fingerprint. Same normalization the watch checks apply.
854+
parsed = { ...base, kind: "error", fingerprint: ref.replace(/^error_/, "") };
852855
break;
853856
case "queue":
854857
parsed = { ...base, kind: "queue", name: ref };
@@ -1536,7 +1539,12 @@ export function buildDashboardAgentTools(ctx: DashboardAgentToolContext): ToolSe
15361539
parsed = { kind: "run", ...scope, runId: destination.runId };
15371540
break;
15381541
case "error":
1539-
parsed = { kind: "error", ...scope, fingerprint: destination.fingerprint };
1542+
// Accept the API's friendly id ("error_<fp>") — the page keys on the raw one.
1543+
parsed = {
1544+
kind: "error",
1545+
...scope,
1546+
fingerprint: destination.fingerprint.replace(/^error_/, ""),
1547+
};
15401548
break;
15411549
case "queue":
15421550
parsed = { kind: "queue", ...scope, name: destination.name };

0 commit comments

Comments
 (0)