Skip to content

Commit 8dea45a

Browse files
committed
fix(webapp): fullscreen toggle remounted the chat; settle-merge missed streaming text
Confirmed both, refuted neither: 1. FloatingAgentWindow (Devin BUG, panel-layout.tsx): fullscreen and floating returned different element trees, so children() sat at a different tree position in each — flipping fullscreen unmounted and remounted the chat panel, resetting its session and interrupting a live stream. Verified with a jsdom test (mount count 1 -> 2 on toggle) before fixing; now one tree shape, only classes/style and a conditional drag-handler object change. 2. mergeSettledMessages (CodeRabbit MAJOR, settled-transcript.ts): stillRunning only checked tool-* parts, so a settled text message never replaced its still-"streaming" in-memory copy — a resumed prose-only chat could stay stuck showing partial text after the server settled it. Widened stillRunning to also treat a `text` part with state "streaming" as running. Item 3 (crumbs markers) intentionally not touched.
1 parent 8e411e1 commit 8dea45a

4 files changed

Lines changed: 104 additions & 46 deletions

File tree

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @vitest-environment jsdom
2-
import { createElement } from "react";
2+
import { createElement, useEffect } from "react";
33
import { createRoot, type Root } from "react-dom/client";
44
import { act } from "react-dom/test-utils";
55
import { afterEach, describe, expect, it } from "vitest";
@@ -174,3 +174,36 @@ describe("FloatingAgentWindow's drag-vs-click filter", () => {
174174
expect(view.outerLeft()).toBe(startLeft);
175175
});
176176
});
177+
178+
describe("FloatingAgentWindow keeps its child mounted across a fullscreen toggle", () => {
179+
it("never remounts the child when `fullscreen` flips (same tree shape both ways)", () => {
180+
let mounts = 0;
181+
function Marker() {
182+
useEffect(() => {
183+
mounts += 1;
184+
}, []);
185+
return null;
186+
}
187+
function Harness({ fullscreen }: { fullscreen: boolean }) {
188+
return createElement(FloatingAgentWindow, { fullscreen }, () => createElement(Marker));
189+
}
190+
191+
container = document.createElement("div");
192+
document.body.appendChild(container);
193+
root = createRoot(container);
194+
act(() => {
195+
root!.render(createElement(Harness, { fullscreen: false }));
196+
});
197+
expect(mounts).toBe(1);
198+
199+
act(() => {
200+
root!.render(createElement(Harness, { fullscreen: true }));
201+
});
202+
expect(mounts).toBe(1);
203+
204+
act(() => {
205+
root!.render(createElement(Harness, { fullscreen: false }));
206+
});
207+
expect(mounts).toBe(1);
208+
});
209+
});

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

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -91,64 +91,67 @@ export function FloatingAgentWindow({
9191
const gestureClassified = useRef(false);
9292
const ignoringGesture = useRef(false);
9393

94-
if (fullscreen) {
95-
return (
96-
<div className={agentTakeoverClassName(true)}>
97-
{children({ dragHandleProps: {}, dragHandleClassName: "" })}
98-
</div>
99-
);
100-
}
101-
10294
const classifyGesture = (event: PointerEvent) => {
10395
if (gestureClassified.current) return;
10496
gestureClassified.current = true;
10597
ignoringGesture.current = !!(event.target as HTMLElement | null)?.closest(NO_DRAG_SELECTOR);
10698
};
10799

108-
const filteredDragHandleProps: Partial<PanHandlerProps> = {
109-
onPanStart: (event: PointerEvent, info: PanInfo) => {
110-
classifyGesture(event);
111-
if (ignoringGesture.current) return;
112-
setDragging(true);
113-
dragHandleProps.onPanStart?.(event, info);
114-
},
115-
onPan: (event: PointerEvent, info: PanInfo) => {
116-
classifyGesture(event);
117-
if (ignoringGesture.current) return;
118-
dragHandleProps.onPan?.(event, info);
119-
},
120-
onPanEnd: (event: PointerEvent, info: PanInfo) => {
121-
gestureClassified.current = false;
122-
ignoringGesture.current = false;
123-
setDragging(false);
124-
dragHandleProps.onPanEnd?.(event, info);
125-
},
126-
};
100+
// Same shape as `dragHandleProps` below empty, so fullscreen (no drag) doesn't change types.
101+
const filteredDragHandleProps: Partial<PanHandlerProps> = fullscreen
102+
? {}
103+
: {
104+
onPanStart: (event: PointerEvent, info: PanInfo) => {
105+
classifyGesture(event);
106+
if (ignoringGesture.current) return;
107+
setDragging(true);
108+
dragHandleProps.onPanStart?.(event, info);
109+
},
110+
onPan: (event: PointerEvent, info: PanInfo) => {
111+
classifyGesture(event);
112+
if (ignoringGesture.current) return;
113+
dragHandleProps.onPan?.(event, info);
114+
},
115+
onPanEnd: (event: PointerEvent, info: PanInfo) => {
116+
gestureClassified.current = false;
117+
ignoringGesture.current = false;
118+
setDragging(false);
119+
dragHandleProps.onPanEnd?.(event, info);
120+
},
121+
};
127122

123+
// Same two-`div` shape in both modes — only classes/style change — so toggling `fullscreen`
124+
// never unmounts `children`; only className/style differ.
128125
return (
129126
<div
130-
style={style}
131-
className="z-20 flex flex-col rounded-lg border border-border-bright bg-background-bright shadow-2xl"
127+
style={fullscreen ? undefined : style}
128+
className={
129+
fullscreen
130+
? agentTakeoverClassName(true)
131+
: "z-20 flex flex-col rounded-lg border border-border-bright bg-background-bright shadow-2xl"
132+
}
132133
>
133134
{/* Clips content to the rounded corners without clipping the resize handles below,
134135
which sit half outside this box's edges. */}
135-
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg">
136+
<div
137+
className={cn("flex min-h-0 flex-1 flex-col", !fullscreen && "overflow-hidden rounded-lg")}
138+
>
136139
{/* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */}
137140
{children({
138141
dragHandleProps: filteredDragHandleProps,
139-
dragHandleClassName: cn(
140-
"select-none touch-none",
141-
dragging ? "cursor-grabbing" : "cursor-grab"
142-
),
142+
dragHandleClassName: fullscreen
143+
? ""
144+
: cn("select-none touch-none", dragging ? "cursor-grabbing" : "cursor-grab"),
143145
})}
144146
</div>
145-
{RESIZE_EDGES.map((edge) => (
146-
<motion.div
147-
key={edge}
148-
{...resizeHandleProps(edge)}
149-
className={draggableResizeHandleClassName(edge)}
150-
/>
151-
))}
147+
{!fullscreen &&
148+
RESIZE_EDGES.map((edge) => (
149+
<motion.div
150+
key={edge}
151+
{...resizeHandleProps(edge)}
152+
className={draggableResizeHandleClassName(edge)}
153+
/>
154+
))}
152155
</div>
153156
);
154157
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ describe("replacing a stale running step from the re-read", () => {
116116
expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]);
117117
expect(merged[0]).toBe(RUNNING_STEP);
118118
});
119+
120+
// A prose-only turn: no tool part, just a `text` part the stream never marked done.
121+
const RUNNING_TEXT = {
122+
id: "msg_text",
123+
role: "assistant",
124+
parts: [{ type: "text", text: "Concurrency on the ", state: "streaming" }],
125+
};
126+
127+
const FINISHED_TEXT = {
128+
id: "msg_text",
129+
role: "assistant",
130+
parts: [
131+
{ type: "text", text: "Concurrency on the `emails` queue hit its limit.", state: "done" },
132+
],
133+
};
134+
135+
it("swaps a still-streaming text part for its settled version too", () => {
136+
const merged = mergeSettledMessages([RUNNING_TEXT], [FINISHED_TEXT]);
137+
expect(merged).toEqual([FINISHED_TEXT]);
138+
expect(transcriptLooksUnfinished(merged)).toBe(false);
139+
});
119140
});
120141

121142
describe("reading the transcript endpoint", () => {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@ import {
1616

1717
type Identified = { id: string };
1818

19-
/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */
19+
/** A message whose stream died mid-tool or mid-text: a part still reads as running. */
2020
function stillRunning(message: unknown): boolean {
2121
const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts;
2222
if (!Array.isArray(parts)) return false;
2323
return parts.some(
2424
(part) =>
25-
typeof part?.type === "string" &&
26-
part.type.startsWith("tool-") &&
27-
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")
25+
(typeof part?.type === "string" &&
26+
part.type.startsWith("tool-") &&
27+
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")) ||
28+
(part?.type === "text" && part.state === "streaming")
2829
);
2930
}
3031

0 commit comments

Comments
 (0)