Skip to content

Commit e705833

Browse files
committed
fix(webapp): resume a chat's in-flight turn on reopen; final comment sweep
Closing the panel already keeps the server turn alive (teardownCancelsTurn returns false for panel-closed and chat-switched). The missing piece was resume-on-reopen: opening a chat never marked it streaming, so the transport never resumed the live `.out` stream and a mid-turn reopen showed a stalled transcript. resolveOpenedChat now derives `streaming` from whether the fetched transcript still looks mid-turn (the same transcriptLooksUnfinished check already used to poll a just-settled turn), and DashboardAgentPanel already spreads it into ActiveChat, so useTriggerChatTransport's isStreaming resume engages on reopen. Chat-switch design decision: unchanged, matches the packet's preference — teardownCancelsTurn already keeps chat-switched turns alive, same as panel-closed, so switching away and back also resumes. A true SSE integration test is impractical in jsdom; resume-wiring.test.ts source-checks the streaming/isStreaming/stopGeneration wiring instead, alongside new resolveOpenedChat unit tests for the streaming derivation. Final comment sweep over the branch diff: every comment capped at two plain lines, non-critical/signpost ones removed.
1 parent c59cf0e commit e705833

12 files changed

Lines changed: 90 additions & 56 deletions

File tree

apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
// Source-level guard: the floating window is the default and only mode. A regression to
2-
// the old slide-in-from-the-right column would reintroduce `ResizablePanelGroup` here —
3-
// this fails red the moment it comes back, instead of waiting for a visual regression.
1+
// Guards against reintroducing the old right-column ResizablePanelGroup.
42
import { readFileSync } from "node:fs";
53
import { join } from "node:path";
64
import { describe, expect, it } from "vitest";

apps/webapp/app/components/dashboard-agent/opened-chat.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,37 @@ const message: UIMessage = {
1010
parts: [{ type: "text", text: "why did this run fail?" }],
1111
};
1212

13+
// A tool call the stream died on: still `input-available`, no result part.
14+
const unfinishedMessage: UIMessage = {
15+
id: "msg_2",
16+
role: "assistant",
17+
parts: [{ type: "tool-run_query", state: "input-available" } as never],
18+
};
19+
1320
describe("resolveOpenedChat", () => {
1421
it("opens a chat that has messages", () => {
1522
const opened = resolveOpenedChat(CHAT_ID, { messages: [message], session: null });
1623

17-
expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [message], session: null });
24+
expect(opened).toEqual({
25+
kind: "chat",
26+
chatId: CHAT_ID,
27+
messages: [message],
28+
session: null,
29+
streaming: false,
30+
});
1831
});
1932

2033
it("still opens a chat that exists but has no messages", () => {
2134
const opened = resolveOpenedChat(CHAT_ID, { messages: [], session: null });
2235

2336
expect(opened.kind).toBe("chat");
24-
expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [], session: null });
37+
expect(opened).toEqual({
38+
kind: "chat",
39+
chatId: CHAT_ID,
40+
messages: [],
41+
session: null,
42+
streaming: false,
43+
});
2544
});
2645

2746
it("treats a chat with no messages field the same way", () => {
@@ -54,4 +73,23 @@ describe("resolveOpenedChat", () => {
5473
it("has no session when the token is missing", () => {
5574
expect(resolveOpenedChat(CHAT_ID, { messages: [message] })).toMatchObject({ session: null });
5675
});
76+
77+
// The bug this guards: closing mid-turn and reopening must resume, not show a stalled turn.
78+
it("marks streaming when the fetched transcript still looks mid-turn", () => {
79+
const opened = resolveOpenedChat(CHAT_ID, {
80+
messages: [message, unfinishedMessage],
81+
session: { publicAccessToken: "pat_1", lastEventId: "evt_9" },
82+
});
83+
84+
expect(opened).toMatchObject({ streaming: true });
85+
});
86+
87+
it("is not streaming once the transcript settles", () => {
88+
const opened = resolveOpenedChat(CHAT_ID, {
89+
messages: [message],
90+
session: { publicAccessToken: "pat_1", lastEventId: "evt_9" },
91+
});
92+
93+
expect(opened).toMatchObject({ streaming: false });
94+
});
5795
});

apps/webapp/app/components/dashboard-agent/opened-chat.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { UIMessage } from "@ai-sdk/react";
2+
import { transcriptLooksUnfinished } from "./settled-transcript";
23

34
export type OpenedChatResponse = {
45
messages?: UIMessage[];
@@ -11,6 +12,8 @@ export type OpenedChat =
1112
chatId: string;
1213
messages: UIMessage[];
1314
session: { publicAccessToken: string; lastEventId?: string } | null;
15+
// True if the transcript still reads as mid-turn, so the transport resumes it.
16+
streaming: boolean;
1417
}
1518
// Deleted, or belonging to someone else: the read failed, so there is no chat to show.
1619
| { kind: "gone" };
@@ -23,15 +26,17 @@ export function resolveOpenedChat(
2326
if (!response) return { kind: "gone" };
2427

2528
const session = response.session;
29+
const messages = response.messages ?? [];
2630
return {
2731
kind: "chat",
2832
chatId,
29-
messages: response.messages ?? [],
33+
messages,
3034
session: session?.publicAccessToken
3135
? {
3236
publicAccessToken: session.publicAccessToken,
3337
lastEventId: session.lastEventId ?? undefined,
3438
}
3539
: null,
40+
streaming: transcriptLooksUnfinished(messages),
3641
};
3742
}

apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ describe("initialFloatingRect", () => {
4444
});
4545
});
4646

47-
// Same render-hook pattern as DraggableResizable.dom.test.ts.
4847
function renderDraggableResizable() {
4948
let latest!: ReturnType<typeof useDraggableResizable>;
5049
function Harness() {

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

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,7 @@ export function agentHiddenContentClassName(fullscreen: boolean): string {
6969
return cn("h-full overflow-hidden", fullscreen && "invisible");
7070
}
7171

72-
/**
73-
* Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`; the caller
74-
* (`DashboardAgent`) supplies it. Owns the drag-vs-click filter so every consumer (the real
75-
* panel, the standalone story) gets identical behavior: a pan starting on a
76-
* `data-agent-no-drag` element (or a descendant of one) never moves the window.
77-
*/
72+
/** Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`, supplied by the caller. */
7873
export function FloatingAgentWindow({
7974
fullscreen,
8075
children,
@@ -89,9 +84,7 @@ export function FloatingAgentWindow({
8984
viewportPadding: FLOATING_MARGIN,
9085
});
9186
const [dragging, setDragging] = useState(false);
92-
// Framer-motion can deliver onPan before onPanStart, so the no-drag check runs on
93-
// whichever event lands first; `gestureClassified` makes sure it only runs once per
94-
// gesture (a late onPanStart must not re-decide after onPan already classified it).
87+
// onPan can arrive before onPanStart, so the no-drag check runs once, on whichever fires first.
9588
const gestureClassified = useRef(false);
9689
const ignoringGesture = useRef(false);
9790

@@ -137,7 +130,7 @@ export function FloatingAgentWindow({
137130
{/* Clips content to the rounded corners without clipping the resize handles below,
138131
which sit half outside this box's edges. */}
139132
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg">
140-
{/* oxlint-disable-next-line react/refs -- filteredDragHandleProps' closures only touch the ref inside their own event handlers, not during this render. */}
133+
{/* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */}
141134
{children({
142135
dragHandleProps: filteredDragHandleProps,
143136
dragHandleClassName: cn(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
4+
// Structural guard: a live SSE resume is impractical in jsdom, so this pins the wiring
5+
// instead. `opened-chat.test.ts` covers the `streaming` value; this covers it reaching the transport.
6+
describe("the panel's resume wiring, source-checked", () => {
7+
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
8+
const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8");
9+
10+
it("marks a reopened chat's session isStreaming from the `streaming` prop", () => {
11+
expect(chat).toContain("isStreaming: streaming ?? false");
12+
});
13+
14+
it("only stopGeneration is called from the explicit Stop button and the gated teardown", () => {
15+
const calls = [...chat.matchAll(/transport\.stopGeneration\(/g)];
16+
expect(calls).toHaveLength(1);
17+
expect(chat).toContain("if (!teardownCancelsTurn(reason)) return;");
18+
});
19+
20+
it("passes the opened chat's streaming flag through to the mounted chat", () => {
21+
expect(panel).toContain("setActive({ ...opened, organizationId: organization.id });");
22+
expect(panel).toContain("streaming={active.streaming}");
23+
});
24+
});

apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
// @vitest-environment jsdom
2-
//
3-
// Framer-motion can deliver onPan before onPanStart for the same gesture (its scheduler
4-
// defers onPanStart by a frame); a start-rect-ref implementation would corrupt its
5-
// baseline when the late onPanStart lands. This drives the hook's real handlers to prove
6-
// it survives that ordering, unlike the pure-math tests in draggableResizableMath.test.ts.
2+
// Framer-motion can deliver onPan before onPanStart; drives the real handlers to prove
3+
// the hook survives that ordering (draggableResizableMath.test.ts only covers the math).
74
import { createElement } from "react";
85
import { createRoot, type Root } from "react-dom/client";
96
import { act } from "react-dom/test-utils";
@@ -47,9 +44,8 @@ function renderHook(options: UseDraggableResizableOptions) {
4744
};
4845
}
4946

50-
// `offset` is populated alongside `delta` (framer-motion always sends both) so a reverted
51-
// offset+baseline implementation runs its real math instead of crashing on `undefined` —
52-
// it must fail on the *value*, not on a missing field.
47+
// `offset` is set too (framer always sends both), so a reverted implementation fails on
48+
// the value, not on a missing field.
5349
function fakePanInfo(deltaX: number, offsetX: number): PanInfo {
5450
return {
5551
delta: { x: deltaX, y: 0 },

apps/webapp/app/components/primitives/DraggableResizable.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,7 @@ export type PanHandlerProps = {
3232
};
3333

3434
export type UseDraggableResizableResult = {
35-
/**
36-
* position:fixed with left/top/width/height set from state. `x`/`y` are the
37-
* top-left corner in viewport coordinates — if the window docks bottom-right,
38-
* derive the initial x/y from `window.innerWidth/innerHeight - w/h - padding`.
39-
*/
35+
/** position:fixed from state; `x`/`y` are the top-left corner in viewport coordinates. */
4036
style: CSSProperties;
4137
dragHandleProps: PanHandlerProps;
4238
resizeHandleProps: (edge: ResizeEdge) => PanHandlerProps;
@@ -75,10 +71,8 @@ export function useDraggableResizable({
7571
return () => window.removeEventListener("resize", onResize);
7672
}, [viewportPadding]);
7773

78-
// Each onPan step folds `info.delta` onto the latest committed rect via functional
79-
// setState, with no gesture-start baseline kept: framer-motion can deliver onPan before
80-
// onPanStart (its scheduler defers onPanStart by a frame), which would make a ref-based
81-
// baseline stale.
74+
// Folds `info.delta` via functional setState, no gesture-start baseline: onPan can
75+
// arrive before onPanStart, which would make a ref-based baseline stale.
8276
const dragHandleProps: PanHandlerProps = {
8377
onPanStart: () => {},
8478
onPan: (_event, info: PanInfo) => {

apps/webapp/app/components/primitives/Popover.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,8 @@ function PopoverArrowTrigger({
223223
>
224224
{children}
225225
</Paragraph>
226-
{/* Wrapper only: `data-agent-no-drag` is an opt-out marker some draggable-window hosts
227-
check via `closest()`, so the icon (not the title text beside it) can decline a drag
228-
without changing this primitive's layout (`contents` keeps the icon as the flex item). */}
226+
{/* `data-agent-no-drag`: an opt-out marker draggable-window hosts check via closest().
227+
`contents` keeps this wrapper invisible to layout. */}
229228
<span data-agent-no-drag className="contents">
230229
<DropdownIcon className={cn("size-4 min-w-4 transition", variantStyles.icon)} />
231230
</span>

apps/webapp/app/components/primitives/draggableResizableMath.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,8 @@ describe("resizeRect — minSize wins over a viewport-derived cap smaller than i
223223
});
224224
});
225225

226-
// These reproduce the live-testing symptoms: framer-motion can deliver a gesture's first
227-
// onPan before its onPanStart, so a baseline captured in onPanStart can be stale.
228-
// applyDragDelta/applyResizeDelta avoid that by folding framer's per-event `delta` onto
229-
// whatever rect is passed in, with no baseline to go stale.
226+
// onPan can arrive before onPanStart, so these prove the delta-folding approach has no
227+
// baseline to go stale across back-to-back gestures.
230228
describe("applyResizeDelta / applyDragDelta — gesture sequencing", () => {
231229
const start: Rect = { x: 100, y: 100, w: 300, h: 200 };
232230
const minSize = { w: 100, h: 80 };

0 commit comments

Comments
 (0)