Skip to content

Commit d4db97d

Browse files
committed
fix(ui): keep working spark through reloads, dodge running tool rows, show mobile interrupted toast
1 parent 13a7975 commit d4db97d

6 files changed

Lines changed: 80 additions & 20 deletions

File tree

ui/src/pages/chat/chat-responsive.browser.test.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,19 +1323,13 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
13231323
expect(settings.y + settings.height).toBeLessThanOrEqual(footer.y + footer.height + 1);
13241324
expect(model.y).toBeGreaterThanOrEqual(textarea.y);
13251325
expect(context.y).toBeGreaterThanOrEqual(textarea.y);
1326-
expect(progress.y).toBeGreaterThanOrEqual(textarea.y);
13271326
expect(
13281327
Math.abs(attach.y + attach.height / 2 - (send.y + send.height / 2)),
13291328
).toBeLessThanOrEqual(2);
13301329
expect(attach.x + attach.width).toBeLessThanOrEqual(textarea.x + 1);
13311330
expect(model.x).toBeGreaterThanOrEqual(settings.x + settings.width - 1);
13321331
expect(send.x).toBeGreaterThanOrEqual(textarea.x + textarea.width - 1);
13331332
expect(send.x + send.width).toBeLessThanOrEqual(input.x + input.width + 1);
1334-
expect(progress.x).toBeGreaterThanOrEqual(context.x + context.width - 1);
1335-
expect(
1336-
Math.abs(progress.y + progress.height / 2 - (context.y + context.height / 2)),
1337-
).toBeLessThanOrEqual(2);
1338-
expect(rectsOverlap(progress, context)).toBe(false);
13391333
expect(rectsOverlap(model, settings)).toBe(false);
13401334
expect(rectsOverlap(model, send)).toBe(false);
13411335
expect(rectsOverlap(settings, send)).toBe(false);
@@ -1348,7 +1342,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
13481342
expect(model.width).toBeLessThanOrEqual(footer.width);
13491343
expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
13501344
expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
1351-
for (const control of [model, settings, context, progress]) {
1345+
for (const control of [model, settings, context]) {
13521346
expect(
13531347
Math.abs(control.y + control.height / 2 - (settings.y + settings.height / 2)),
13541348
).toBeLessThanOrEqual(2);

ui/src/pages/chat/chat-thread.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,55 @@ function messageRecord(group: MessageGroup, index = 0): Record<string, unknown>
5353
return requireRecord(group.messages[index]?.message);
5454
}
5555

56+
describe("buildChatItems working spark", () => {
57+
const hasReadingIndicator = (props: Partial<BuildChatItemsProps>) =>
58+
buildChatItems(createProps(props)).some((item) => item.kind === "reading-indicator");
59+
const liveTool = (resultReceived: boolean) => ({
60+
role: "assistant",
61+
toolCallId: "tool-1",
62+
content: [{ type: "toolcall", name: "exec", arguments: {} }],
63+
timestamp: 1_000,
64+
__openclawToolStreamLive: true,
65+
__openclawToolStreamResultReceived: resultReceived,
66+
});
67+
68+
it("shows the spark while a run works with nothing streaming", () => {
69+
expect(hasReadingIndicator({ runWorking: true })).toBe(true);
70+
});
71+
72+
it("keeps the spark during a background reload with visible content", () => {
73+
expect(
74+
hasReadingIndicator({
75+
runWorking: true,
76+
loading: true,
77+
messages: [{ role: "assistant", content: "answer", timestamp: 1 }],
78+
}),
79+
).toBe(true);
80+
});
81+
82+
it("yields to the initial-load skeleton on an empty thread", () => {
83+
expect(hasReadingIndicator({ runWorking: true, loading: true })).toBe(false);
84+
});
85+
86+
it("does not stack the spark under a visible running tool row", () => {
87+
expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(false)] })).toBe(false);
88+
});
89+
90+
it("returns the spark once the running tool resolves", () => {
91+
expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(true)] })).toBe(true);
92+
});
93+
94+
it("keeps the spark when tool calls are hidden", () => {
95+
expect(
96+
hasReadingIndicator({
97+
runWorking: true,
98+
showToolCalls: false,
99+
toolMessages: [liveTool(false)],
100+
}),
101+
).toBe(true);
102+
});
103+
});
104+
56105
describe("buildChatItems", () => {
57106
it("keeps consecutive user messages from different senders in separate groups", () => {
58107
const groups = messageGroups({

ui/src/pages/chat/chat-thread.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type BuildChatItemsProps = {
4848
showToolCalls: boolean;
4949
/** True while the agent is visibly working (isChatRunWorking). */
5050
runWorking?: boolean;
51+
/** True while chat history is loading (initial load or background reload). */
52+
loading?: boolean;
5153
searchOpen?: boolean;
5254
searchQuery?: string;
5355
historyRenderLimit?: number;
@@ -1306,9 +1308,23 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
13061308
// streaming (pre-first-token, or a queued send in flight), the thread shows
13071309
// the reading indicator where the reply will materialize. Streaming text
13081310
// and running tool rows take over as the signal once content flows.
1311+
// A visible running tool row already signals active work, so the spark is
1312+
// suppressed rather than stacked under it; hidden tool calls keep the spark.
1313+
const hasVisibleRunningTool =
1314+
props.showToolCalls &&
1315+
tools.some((message) => {
1316+
const record = asRecord(message);
1317+
return (
1318+
record?.__openclawToolStreamLive === true &&
1319+
record.__openclawToolStreamResultReceived !== true
1320+
);
1321+
});
1322+
// The initial-load skeleton owns the empty thread; a background reload with
1323+
// content still visible keeps the spark (it is the only working signal).
1324+
const initialHistoryLoad = props.loading === true && items.length === 0;
13091325
const hasPendingResponse =
13101326
props.stream === null &&
1311-
(props.runWorking === true ||
1327+
((props.runWorking === true && !hasVisibleRunningTool && !initialHistoryLoad) ||
13121328
queuedSends.some(
13131329
(item) => item.sendState === "sending" && shouldRenderQueuedSendInThread(item),
13141330
));
@@ -1357,6 +1373,7 @@ function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsP
13571373
previous.queue === next.queue &&
13581374
previous.showToolCalls === next.showToolCalls &&
13591375
previous.runWorking === next.runWorking &&
1376+
previous.loading === next.loading &&
13601377
previous.searchOpen === next.searchOpen &&
13611378
previous.searchQuery === next.searchQuery &&
13621379
previous.historyRenderLimit === next.historyRenderLimit

ui/src/pages/chat/chat-view.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const buildChatItemsMock = vi.hoisted(() =>
6464
stream: string | null;
6565
streamStartedAt: number | null;
6666
runWorking?: boolean;
67+
loading?: boolean;
6768
}) => {
6869
if (
6970
props.messages.some(
@@ -104,7 +105,8 @@ const buildChatItemsMock = vi.hoisted(() =>
104105
}
105106
// Mirrors buildChatItems: streamed text renders as a stream item; an
106107
// empty stream or a working run with no stream shows the reading
107-
// indicator (working spark).
108+
// indicator (working spark), except on the initial empty load where
109+
// the skeleton owns the thread.
108110
if (props.stream !== null) {
109111
items.push(
110112
props.stream
@@ -117,7 +119,10 @@ const buildChatItemsMock = vi.hoisted(() =>
117119
}
118120
: { kind: "reading-indicator", key: "reading:test" },
119121
);
120-
} else if (props.runWorking === true) {
122+
} else if (
123+
props.runWorking === true &&
124+
!(props.loading === true && props.messages.length === 0)
125+
) {
121126
items.push({ kind: "reading-indicator", key: "reading:test" });
122127
}
123128
return items;

ui/src/pages/chat/components/chat-thread.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -758,9 +758,8 @@ export function renderChatThread(props: ChatThreadProps) {
758758
streamStartedAt: props.streamStartedAt,
759759
queue: props.queue,
760760
showToolCalls: props.showToolCalls,
761-
// Initial history load keeps the skeleton as the empty-thread signal; the
762-
// working spark takes over once content (or a send/stream) exists.
763-
runWorking: Boolean(props.runWorking) && !props.loading,
761+
runWorking: Boolean(props.runWorking),
762+
loading: Boolean(props.loading),
764763
searchOpen: state.searchOpen,
765764
searchQuery: state.searchQuery,
766765
historyRenderLimit,

ui/src/styles/chat/layout.css

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2739,19 +2739,15 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
27392739
.agent-chat__composer-controls {
27402740
order: 0;
27412741
display: grid;
2742-
grid-template-columns: 40px minmax(0, max-content);
2742+
/* Three columns: settings, the transient interrupted toast (absent in
2743+
the normal state, so its column collapses), and the model picker. */
2744+
grid-template-columns: 40px minmax(0, max-content) minmax(0, max-content);
27432745
flex: 1 1 0;
27442746
width: auto;
27452747
margin-left: 0;
27462748
gap: 0;
27472749
}
27482750

2749-
/* The interrupted toast stays desktop-only; the composer grid has no slot
2750-
for it and the sr-only run-status region still announces on mobile. */
2751-
.agent-chat__composer-run-status {
2752-
display: none;
2753-
}
2754-
27552751
.agent-chat__composer-meta {
27562752
margin-left: 0;
27572753
}

0 commit comments

Comments
 (0)