Skip to content

Commit 02d60a4

Browse files
committed
chore: merge feat/dashboard-agent-ui (review fixes round 3)
2 parents 6a33661 + a964b09 commit 02d60a4

6 files changed

Lines changed: 64 additions & 2 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
22
import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
5+
import { composerKeepsEscape } from "./composer-escape";
56
import {
67
MAX_MESSAGE_CHARS,
78
MESSAGE_CHARS_WARN_AT,
@@ -95,6 +96,10 @@ export function DashboardAgentComposer({
9596
e.preventDefault();
9697
onSubmit();
9798
}
99+
// Keeping Escape from the panel's close handler, which skips a prevented event.
100+
if (e.key === "Escape" && composerKeepsEscape(value)) {
101+
e.preventDefault();
102+
}
98103
// Only while empty, so with text present Tab keeps its normal focus behavior.
99104
if (e.key === "Tab" && !e.shiftKey && placeholderSuggestion && value === "") {
100105
e.preventDefault();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export type DashboardAgentMessagesProps = {
4545

4646
// Cached so a stripped message keeps its identity across renders and memoization holds:
4747
// rebuilding it re-renders every tool-calling turn on each streamed token.
48+
// Relies on @ai-sdk/react cloning a message per update: an SDK mutating one in place would
49+
// keep serving the cached copy of its earlier state.
4850
const strippedMessages = new WeakMap<UIMessage, UIMessage>();
4951

5052
export function stripStepParts(message: UIMessage): UIMessage {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { composerKeepsEscape } from "./composer-escape";
3+
4+
describe("Escape while the composer has focus", () => {
5+
it("is kept by the composer while there is a draft, so the panel stays open", () => {
6+
expect(composerKeepsEscape("half a question about a failing run")).toBe(true);
7+
});
8+
9+
it("closes the panel when there is nothing to lose", () => {
10+
expect(composerKeepsEscape("")).toBe(false);
11+
});
12+
13+
it("reads whitespace as nothing to lose, matching what Send accepts", () => {
14+
expect(composerKeepsEscape(" \n ")).toBe(false);
15+
});
16+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* The panel closes on Escape unless a child has already prevented the event's default —
3+
* `defaultPrevented` is how a child vetoes the close. A composer holding a draft takes the
4+
* first Escape for itself, so the draft survives; an empty one lets Escape close the panel.
5+
*/
6+
export function composerKeepsEscape(value: string): boolean {
7+
return value.trim() !== "";
8+
}

apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,36 @@ describe("an already-open panel when a turn is exhausted", () => {
239239
expect(reads).toBe(2);
240240
});
241241

242+
it("keeps re-reading a stream that died mid-tool with no card open", async () => {
243+
// No investigation anywhere: only the dangling `get_report` says the turn is unfinished.
244+
const DANGLING = {
245+
id: "msg_step",
246+
role: "assistant",
247+
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
248+
};
249+
const FINISHED = {
250+
id: "msg_step",
251+
role: "assistant",
252+
parts: [
253+
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
254+
],
255+
};
256+
257+
const responses = [[DANGLING], [DANGLING], [FINISHED]];
258+
let rendered: (typeof DANGLING)[] = [DANGLING];
259+
let reads = 0;
260+
261+
await pollSettledTranscript({
262+
fetchTranscript: async () => responses[reads++] ?? null,
263+
apply: (merge) => void (rendered = merge(rendered)),
264+
wait: async () => {},
265+
});
266+
267+
expect(reads).toBe(3);
268+
expect(rendered).toEqual([FINISHED]);
269+
expect(transcriptLooksUnfinished(rendered)).toBe(false);
270+
});
271+
242272
it("stops on a failed re-read instead of hammering the endpoint", async () => {
243273
let reads = 0;
244274
await pollSettledTranscript<typeof OPEN>({

apps/webapp/app/components/dashboard-agent/settled-transcript.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ export async function pollSettledTranscript<T extends Identified>(deps: {
8686
const fetched = await deps.fetchTranscript();
8787
if (!fetched) return;
8888
deps.apply((current) => mergeSettledMessages(current, fetched));
89-
// The stored transcript is the authority on whether anything is still open.
90-
if (!hasOpenInvestigation(fetched)) return;
89+
// The stored transcript is the authority on whether anything is still open. Same test that
90+
// starts the poll, so a stream that died mid-tool is followed until it settles too.
91+
if (!transcriptLooksUnfinished(fetched)) return;
9192
}
9293
}
9394

0 commit comments

Comments
 (0)