Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
500630a
feat(chat): show a sent message until the agent turns it into a turn
CharleoYang Oct 28, 2026
8a27ce6
feat(composer): an unconfirmed send is information, not an alert
CharleoYang Oct 28, 2026
3293e36
feat(composer): a send whose receipt never came is settled by the pan…
CharleoYang Oct 28, 2026
e978f79
feat(composer): omp and pi settle a lost receipt by their editor row too
CharleoYang Oct 28, 2026
77a61c4
Merge origin/main into feat/send-settlement
CharleoYang Oct 28, 2026
b1846f3
Fix informational composer notes offering duplicate sends
CharleoYang Oct 28, 2026
ebd5b50
Track confirmed sends independently until their own transcript turns
CharleoYang Oct 28, 2026
e1afb01
Merge origin/main into feat/send-settlement
devswha Oct 29, 2026
76e306d
Drop the pane draft reader nothing calls
devswha Oct 29, 2026
1ff5640
Settle a waiting send only by its own whole, later turn
devswha Oct 29, 2026
c37d539
Keep OpenCode's partly typed send an alert
devswha Oct 29, 2026
15f3967
Show a waiting send as the user's quiet bubble
devswha Oct 29, 2026
35b3890
Move the changelog entry to a fragment
devswha Oct 29, 2026
6a60102
Merge origin/main into feat/send-settlement
devswha Oct 29, 2026
e8a2b43
Keep submit_partial an alert: the server raises it also when nothing …
devswha Oct 29, 2026
67855a7
Bound a waiting send by what it typed, a replaced herdr and a minute
devswha Oct 29, 2026
20594ef
Settle waiting bubbles before paint in one status region
devswha Oct 29, 2026
666e81b
Bound an unshown turn by every shown turn and by the chat's first read
devswha Oct 29, 2026
0198269
Allow whole-second transcript times; take a baseline only from a conv…
devswha Oct 29, 2026
b364cc6
Show a waiting send as the user's faded bubble, with a clock only aft…
devswha Oct 29, 2026
0141492
Settle a whole-second turn before the first read, drop waiting sends …
devswha Oct 29, 2026
7569e84
Keep a new send waiting past an older same-second turn before the fir…
devswha Oct 29, 2026
2ee62ab
Merge remote-tracking branch 'origin/main' into mp4-2270-merge
devswha Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/chat-sent-waiting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Changed
- A message sent from Chat shows at once as your own bubble, faded, until the agent writes it as a
turn, so the chat is never blank between Send and the agent's turn. A small clock joins it only
when the agent has not taken it after 5 seconds. A command (`/cost`, `! ls`) gets no bubble, since
it never comes back as that message, and a bubble whose turn has not come within a minute goes.
30 changes: 30 additions & 0 deletions src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,36 @@ ol.markdown-list {

.chat-inline-error { color: var(--status-blocked); }

/* a sent message the agent has not written as a turn yet (SentWaiting.tsx): the user's own bubble,
its words faded to --text-dim (still 4.5:1), so the turn that replaces it reads as the same bubble
coming up to full strength. A late one's clock sits where the turn's time will be */
.chat-sent-waiting-list {
display: flex;
flex-direction: column;
align-self: stretch;
gap: var(--space-2);
}

.chat-turn-waiting .chat-bubble {
color: var(--text-dim);
white-space: pre-wrap;
}

.chat-turn-waiting .chat-turn-meta {
opacity: 1;
}

.chat-sent-late {
display: inline-flex;
color: var(--text-dim);
font-size: var(--chat-fs-xs);
}

.chat-sent-late svg {
width: 1em;
height: 1em;
}

/* a read that failed (ChatView.tsx FailureLine): the one line, Try again beside it where it is the
way on, and a dim Details toggle under them for the advice and the PC's own words */
.chat-failure {
Expand Down
14 changes: 14 additions & 0 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { followGlides } from "../lib/followScroll.ts";
import { useMediaQuery } from "../lib/useMediaQuery.ts";
import { statusEdgeRead } from "../lib/status.ts";
import { askedQuestions } from "../../shared/askQuestions.ts";
import { settleWaitingSends, waitingKey } from "../lib/sentWaiting.ts";
import { SentWaitingList, useWaitingSends } from "./SentWaiting.tsx";
import { ConversationRefresh } from "../lib/conversationRefresh.ts";
import { teammateMessageParts, type TeammateMessage } from "../lib/teammateMessage.ts";
import { usePageVisible } from "../lib/visibility.ts";
Expand Down Expand Up @@ -2321,10 +2323,22 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish
// empty (never display: none); its words land a frame after it (lib/useAnnouncedText.ts). The
// connection's word is the header chip's alone; a slow PC is waited for quietly before it alarms
const waiting = error !== null && error.kind === "slow" && failures < SLOW_READS_BEFORE_ALARM;
// A message this browser sent that the pane has not written as a turn yet: Claude records the user
// turn only when its next turn starts, so without this the chat would show nothing at all. The
// queue lives in the store, so a reader who goes to another tab and comes back still sees it.
const waitKey = waitingKey(machineId, paneId);
const sentWaiting = useWaitingSends(machineId, paneId, connected);
// before paint: a frame never shows a send's turn and its waiting bubble together. Only a
// conversation read that answered says what the chat holds: a failed read leaves it unknown
const turnsRead = loaded && error === null && state.source === "conversation";
useLayoutEffect(() => {
settleWaitingSends(waitKey, state.turns, Date.now(), turnsRead);
}, [sentWaiting, state.turns, turnsRead, waitKey]);
const waitWords = useAnnouncedText(!loaded && error === null ? t("Loading conversation…") : waiting ? t("Waiting for the PC…") : null);
const retryRead = useCallback(() => { setFailures(0); conversationRefresh.refresh(); }, [conversationRefresh]);
const footer = <>
<p className={waitWords === null ? "visually-hidden" : "chat-inline-state"} role="status">{waitWords}</p>
{sentWaiting.length > 0 && state.source === "conversation" && <SentWaitingList sends={sentWaiting} />}
{error !== null && !waiting && (errorStatus === 401
? <p className="chat-inline-state chat-inline-error" role="alert">{t("locked — the token gate is asking again")}</p>
: <FailureLine action={t("Couldn't load the conversation")} failure={error} onRetry={retryRead} />)}
Expand Down
120 changes: 120 additions & 0 deletions src/components/SentWaiting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, jest, setSystemTime, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";

import { SettingsProvider } from "../lib/settings.ts";
import { forgetWaitingSends, rememberWaitingSend, WAITING_HINT_MS, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts";
import { SentWaitingList, useWaitingSends } from "./SentWaiting.tsx";

const at = Date.parse("2026-10-29T03:00:00Z");
const send: WaitingSend = { id: 1, pane: "local:p1", text: "fix the flaky test", at, after: null };

function withEnglish<T>(run: () => T): T {
const saved = Object.getOwnPropertyDescriptor(navigator, "languages");
Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] });
try { return run(); } finally {
if (saved) Object.defineProperty(navigator, "languages", saved);
else Reflect.deleteProperty(navigator, "languages");
}
}

function render(now: number): string {
setSystemTime(new Date(now));
return withEnglish(() => renderToStaticMarkup(createElement(SettingsProvider, { children: createElement(SentWaitingList, { sends: [send] }) })));
}
const textWithoutTags = (markup: string): string => markup.replace(/<[^>]*>/gu, "");

afterEach(() => { setSystemTime(); });

describe("SentWaitingList", () => {
test("shows only the sent words, with no caption, while the agent is about to take them", () => {
for (const now of [at, at + WAITING_HINT_MS - 1]) {
const markup = render(now);
expect(textWithoutTags(markup)).toBe(send.text);
expect(markup).not.toContain("chat-sent-late");
}
});

test("adds a clock beside the bubble once the turn is late, still with no caption", () => {
const markup = render(at + WAITING_HINT_MS);
expect(markup).toContain("chat-sent-late");
expect(textWithoutTags(markup)).toBe(send.text);
});

test("is no live region of its own: the chat footer's one status region stays the only one", () => {
expect(render(at + WAITING_HINT_MS)).not.toMatch(/role="(?:status|alert|log)"|aria-live/u);
});
});

describe("mounted", () => {
let renderers: ReactTestRenderer[];
const globals = ["IS_REACT_ACT_ENVIRONMENT", "window", "document", "getComputedStyle"];
const saved = new Map<string, PropertyDescriptor | undefined>();
beforeEach(() => {
renderers = [];
for (const name of globals) saved.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
// only what SettingsProvider reads and writes: no stored settings, a root it themes, no listeners that fire
const style = { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => "", backgroundColor: "" };
const listen = { addEventListener: () => {}, removeEventListener: () => {} };
const values: Record<string, unknown> = {
IS_REACT_ACT_ENVIRONMENT: true,
window: { ...listen, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {}, key: () => null, length: 0 } },
document: { ...listen, documentElement: { lang: "", dataset: {}, style }, querySelector: () => null },
getComputedStyle: () => style,
};
for (const name of globals) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: values[name] });
jest.useFakeTimers();
jest.setSystemTime(new Date(at));
});
afterEach(() => {
act(() => { for (const renderer of renderers) renderer.unmount(); });
jest.useRealTimers();
for (const [name, descriptor] of saved) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
saved.clear();
});
const mount = (element: ReturnType<typeof createElement>, settings = true): ReactTestRenderer => {
let renderer!: ReactTestRenderer;
withEnglish(() => act(() => { renderer = create(settings ? createElement(SettingsProvider, { children: element }) : element); }));
renderers.push(renderer);
return renderer;
};
const clocks = (renderer: ReactTestRenderer): number => renderer.root.findAll((node) => node.props.className === "chat-sent-late").length;

test("brings the clock in at five seconds, on its own timer", () => {
const renderer = mount(createElement(SentWaitingList, { sends: [send] }));
act(() => { jest.advanceTimersByTime(WAITING_HINT_MS - 1); });
expect(clocks(renderer)).toBe(0);
act(() => { jest.advanceTimersByTime(1); });
expect(clocks(renderer)).toBe(1);
expect(jest.getTimerCount()).toBe(0);
});

test("leaves no timer behind when the bubble goes before its clock", () => {
const renderer = mount(createElement(SentWaitingList, { sends: [send] }));
expect(jest.getTimerCount()).toBe(1);
act(() => { renderer.update(createElement(SettingsProvider, { children: createElement(SentWaitingList, { sends: [] }) })); });
expect(jest.getTimerCount()).toBe(0);
});

test("lets a waiting send go when the connection does, and does not bring it back on reconnect", () => {
const key = waitingKey("local", "w1:p1");
let shown: readonly WaitingSend[] = [];
function Probe({ connected }: { connected: boolean }) {
shown = useWaitingSends("local", "w1:p1", connected);
return null;
}
try {
rememberWaitingSend(key, "deploy it", at);
const renderer = mount(createElement(Probe, { connected: true }), false);
expect(shown.map((item) => item.text)).toEqual(["deploy it"]);
act(() => { renderer.update(createElement(Probe, { connected: false })); });
expect(shown).toEqual([]);
act(() => { renderer.update(createElement(Probe, { connected: true })); });
expect(shown).toEqual([]);
} finally { forgetWaitingSends(key); }
});
});
53 changes: 53 additions & 0 deletions src/components/SentWaiting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Clock } from "lucide-react";

import { useT } from "../lib/i18n.ts";
import { currentWaitingSends, forgetMachineWaitingSends, subscribeWaitingSends, WAITING_HINT_MS, waitingKey, type WaitingSend } from "../lib/sentWaiting.ts";

/**
* The sends this browser made to a pane that its transcript does not hold yet. A lost connection
* lets the PC's go, before paint: what became of them is the transcript's to say once it is read
* again, and nothing is sent again.
*/
export function useWaitingSends(machineId: string, paneId: string, connected: boolean): readonly WaitingSend[] {
const key = waitingKey(machineId, paneId);
useLayoutEffect(() => {
if (!connected) forgetMachineWaitingSends(machineId);
}, [connected, machineId]);
return useSyncExternalStore(subscribeWaitingSends, () => currentWaitingSends(key));
}

/**
* Sent, and the agent has not written it as a turn yet: the user's bubble where the turn will be,
* faded, with nothing under it, as a messenger shows a message at once on Send. Its turn takes its
* place when the transcript has it; a clock joins it, where the turn's time will be, only when that
* takes longer than WAITING_HINT_MS.
*/
export function SentWaitingList({ sends }: { sends: readonly WaitingSend[] }) {
const t = useT();
const [now, setNow] = useState(Date.now);
const nextHint = sends.reduce((soonest, send) => {
const due = send.at + WAITING_HINT_MS;
return due > now && due < soonest ? due : soonest;
}, Number.POSITIVE_INFINITY);
useEffect(() => {
if (nextHint === Number.POSITIVE_INFINITY) return;
const timer = setTimeout(() => setNow(Date.now()), Math.max(0, nextHint - Date.now()));
return () => clearTimeout(timer);
}, [nextHint]);
const late = t("Sent · waiting for the agent's next turn");
// no live region of its own: the chat footer has one, and the user's own words need no announcing
return <div className="chat-sent-waiting-list">
{sends.map((send) => {
const chars = Array.from(send.text);
return <div key={send.id} className="chat-turn chat-turn-user chat-turn-waiting">
<div className="chat-user-row">
<div className="chat-bubble"><div className="chat-bubble-body">{chars.length > 160 ? `${chars.slice(0, 160).join("")}…` : send.text}</div></div>
{now - send.at >= WAITING_HINT_MS && <div className="chat-turn-meta">
<span className="chat-sent-late" role="img" aria-label={late} title={late}><Clock aria-hidden="true" /></span>
</div>}
</div>
</div>;
})}
</div>;
}
2 changes: 1 addition & 1 deletion src/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const PART_FILES: Record<string, string[]> = {
settings: ["components/SettingsDialog.tsx", "components/KeyBarSettings.tsx", "lib/paletteSettings.ts", "components/TelemetryControls.tsx", "components/SettingsControls.tsx"],
terminal: ["components/PaneTerminal.tsx", "lib/useTerminalMount.ts", "lib/useComposerSends.ts", "lib/usePeekReturn.ts", "lib/useTypedKeys.ts", "components/PaneCanvas.tsx", "components/KeyBar.tsx", "components/FindBar.tsx", "components/TerminalViewportTools.tsx", "components/SplitDropPreview.tsx", "components/TabStrip.tsx", "lib/terminalLabel.ts", "lib/tabName.ts"],
composer: ["components/Composer.tsx", "components/ComposerNotices.tsx", "components/VoiceInput.tsx", "components/SecretInput.tsx", "components/TerminalInput.tsx", "components/PendingMessages.tsx", "lib/compose.ts", "components/PromptCard.tsx", "lib/promptAnswer.ts", "../shared/card-text.ts"],
chat: ["components/ChatView.tsx", "components/Markdown.tsx", "components/MermaidLightbox.tsx", "components/ChatMinimap.tsx", "components/ImageLightbox.tsx", "components/BackgroundTasks.tsx", "components/BackgroundAgentDetail.tsx", "components/MarkdownLinkMenu.tsx", "lib/workBlocks.ts", "lib/toolVerbs.ts", "lib/outcomeChip.ts", "lib/chatTimestamp.ts", "lib/chatReadDeadline.ts"],
chat: ["components/ChatView.tsx", "components/SentWaiting.tsx", "components/Markdown.tsx", "components/MermaidLightbox.tsx", "components/ChatMinimap.tsx", "components/ImageLightbox.tsx", "components/BackgroundTasks.tsx", "components/BackgroundAgentDetail.tsx", "components/MarkdownLinkMenu.tsx", "lib/workBlocks.ts", "lib/toolVerbs.ts", "lib/outcomeChip.ts", "lib/chatTimestamp.ts", "lib/chatReadDeadline.ts"],
sidebar: ["components/Sidebar.tsx", "components/AgentSidebar.tsx", "components/SidebarResizer.tsx"],
machines: ["components/MachineSidebar.tsx", "components/MachineDialog.tsx", "lib/bridgeProgress.ts"],
workspace: ["components/NewSessionDialog.tsx", "components/WorktreeDialog.tsx", "components/DirectoryBrowser.tsx", "components/RecentFolderChips.tsx", "components/SessionHistory.tsx", "lib/sessionHistory.ts", "lib/useAgentSkills.ts"],
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/chat.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,5 @@ export const CHAT_JA: Record<string, string> = {
"Show full message": "メッセージ全体を表示",
"Open link": "リンクを開く",
"Share link": "リンクを共有",
"Sent · waiting for the agent's next turn": "送信済み · エージェントの次のターンを待っています",
};
1 change: 1 addition & 0 deletions src/lib/i18n/chat.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,5 @@ export const CHAT_KO: Record<string, string> = {
"Show full message": "전체 메시지 보기",
"Open link": "링크 열기",
"Share link": "링크 공유",
"Sent · waiting for the agent's next turn": "전송됨 · 에이전트의 다음 턴을 기다리는 중",
};
1 change: 1 addition & 0 deletions src/lib/i18n/chat.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,5 @@ export const CHAT_ZH: Record<string, string> = {
"Show full message": "显示完整消息",
"Open link": "打开链接",
"Share link": "分享链接",
"Sent · waiting for the agent's next turn": "已发送 · 等待 agent 的下一轮",
};
Loading
Loading