Skip to content

Commit 56031ae

Browse files
committed
feat(web): add localStorage shim for Node environments and enhance AssistantMessageBody to handle silent turn endings
1 parent 99b2e60 commit 56031ae

3 files changed

Lines changed: 53 additions & 2 deletions

File tree

web/src/components/chat-tab.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,18 +898,25 @@ function ChatInput({
898898
function AssistantMessageBody({
899899
message,
900900
isStreaming,
901+
isLast,
901902
sessionId,
902903
onViewInNotebook,
903904
}: {
904905
message: ChatMessage;
905906
isStreaming: boolean;
907+
isLast: boolean;
906908
sessionId: string | null;
907909
onViewInNotebook?: (entryId: string) => void;
908910
}) {
909911
const activities = message.activities ?? [];
910912
const hasReasoning = Boolean(message.reasoning?.trim());
911913
const hasAnything =
912914
Boolean(message.content) || activities.length > 0 || hasReasoning;
915+
// Some models occasionally end a turn right after a tool call with no
916+
// closing text, which used to leave the chat silently "done". Surface that
917+
// explicitly on the final bubble so the user knows the run ended.
918+
const endedWithoutReply =
919+
!isStreaming && isLast && !message.content && (activities.length > 0 || hasReasoning);
913920

914921
// Interview tool calls render as interactive forms, and notebook calls as
915922
// compact pointer chips, in stream order between the surrounding tool cards
@@ -948,6 +955,11 @@ function AssistantMessageBody({
948955
<Shimmer className="text-sm" duration={1.5}>
949956
Thinking...
950957
</Shimmer>
958+
) : endedWithoutReply ? (
959+
<p className="text-xs italic text-muted-foreground">
960+
The model finished this turn without a closing message. The tool
961+
results above are the outcome; ask a follow-up if you want a summary.
962+
</p>
951963
) : null}
952964
{message.citations && (
953965
<div className="flex flex-wrap items-center gap-2">
@@ -1358,13 +1370,14 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
13581370
description="I can research topics, write code, and analyze data."
13591371
/>
13601372
) : (
1361-
messages.map((message) => (
1373+
messages.map((message, i) => (
13621374
<Message from={message.role} key={message.id}>
13631375
<MessageContent>
13641376
{message.role === "assistant" ? (
13651377
<AssistantMessageBody
13661378
message={message}
13671379
isStreaming={isStreaming}
1380+
isLast={i === messages.length - 1}
13681381
sessionId={sessionId}
13691382
onViewInNotebook={onViewInNotebook}
13701383
/>

web/src/lib/use-notebook-annotations.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,20 @@ export function useNotebookAnnotations(sessionId: string | null, enabled: boolea
4646
if (!res.ok) return; // absent sidecar → keep empty envelope
4747
const data = normalizeDoc(await res.json());
4848
if (!cancelled && sessionId === currentSessionRef.current) {
49-
setDoc(data);
49+
// Merge rather than replace: an optimistic mutation made while this
50+
// GET was in flight (e.g. a note added right after opening the
51+
// notebook) must not be clobbered by the server's older copy.
52+
setDoc((cur) => {
53+
if (cur.annotations.length === 0) return data;
54+
const serverIds = new Set(data.annotations.map((a) => a.id));
55+
return {
56+
version: 1,
57+
annotations: [
58+
...data.annotations,
59+
...cur.annotations.filter((a) => !serverIds.has(a.id)),
60+
],
61+
};
62+
});
5063
lastModifiedRef.current = res.headers.get("Last-Modified");
5164
}
5265
} catch {

web/vitest.setup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,31 @@ if (typeof Element !== "undefined" && typeof Element.prototype.scrollIntoView ==
2525
Element.prototype.scrollIntoView = () => {};
2626
}
2727

28+
// On Node >= 22 with experimental WebStorage, Node's own `localStorage`
29+
// global shadows jsdom's implementation; without --localstorage-file it is
30+
// undefined, breaking every test that touches window.localStorage.
31+
if (typeof window !== "undefined" && !window.localStorage) {
32+
const store = new Map<string, string>();
33+
const localStorageShim: Storage = {
34+
get length() {
35+
return store.size;
36+
},
37+
clear: () => store.clear(),
38+
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
39+
key: (index: number) => [...store.keys()][index] ?? null,
40+
removeItem: (key: string) => {
41+
store.delete(key);
42+
},
43+
setItem: (key: string, value: string) => {
44+
store.set(key, String(value));
45+
},
46+
};
47+
Object.defineProperty(window, "localStorage", {
48+
configurable: true,
49+
value: localStorageShim,
50+
});
51+
}
52+
2853
if (typeof window !== "undefined" && typeof window.matchMedia === "undefined") {
2954
Object.defineProperty(window, "matchMedia", {
3055
writable: true,

0 commit comments

Comments
 (0)