Skip to content

Commit b8d5f1e

Browse files
op7418claude
andcommitted
fix(chat): preserve screenshot/attachments when a send isn't delivered (#615)
用户报告:对话框发送截图后,截图直接消失且未真正发出。根因有三层,逐层修复, 其中 2、3 层由 Codex 真机 UI 冒烟发现并驱动: 1) onSend 投递契约。`onSend` 从 `=> void` 升级为 `=> boolean | void | Promise<…>`: 返回 false = 未被接受投递(provider 加载中 / 无兼容 provider / runtime 不兼容 / 禁用)→ composer 保留文字+附件;true/void = 已接受(发出或入队)→ 清空。 - MessageInput:带附件的 no-send 路径(badge+streaming / disabled / 两处 onSend gated)统一走新 helper abortComposerSubmit(throw → PromptInput reject 分支不 清空),不再裸 return;clears 移到 delivered 判定之后;run-checkpoint 块并入该 helper。 - ChatView.sendMessage:三个丢消息门(provider idle / noCompatible / runtime-incompatible)返回 false;入队与成功保持已投递。 - page.tsx sendFirstMessage:5 个门返回 false。 - useAssistantTrigger:sendMessageRef 类型放宽(自动触发忽略返回值)。 2) pre-delivery 失败(Codex 冒烟一)。session 创建 500 / POST /api/chat 被拒时 catch 原来只设 banner、没回传未投递。新增 accepted 标记:仅 POST 2xx 后置真;catch 末尾 if (!accepted) return false → 失败保留 composer。 3) remount 根因(Codex 冒烟二)。isNewChat 布局三元把 composer 渲染在两个不同父容器 (居中 hero / 底部布局);旧代码点发送即 setIsStreaming(true) 翻转 isNewChat → composer 跨父 remount → PromptInput 本地附件 state 销毁,早于得知失败(稳定 key 修不了跨父 remount)。改:把 setIsStreaming / 乐观气泡等驱动布局的翻转全部延后到 accepted 之后;加 firstSendInFlightRef 双提交守卫;composer 栈 siblings 加稳定 key 兜 ErrorBanner 同父重建。 测试:新增 composer-send-preservation.test.ts(source-pin 三文件的 no-send / accepted / remount 契约);run-checkpoint-blocking.test.ts 的 throw 断言适配 helper。 triage 文档标记 #615 已修、Ollama/模型列表/串会话本轮不做(待复现)。 验证:tsc 0 错误;单元全量 3274 通过。正常路径带图发送经 Codex 冒烟确认 (/api/chat body 含 files[0]);两条注入失败路径由 Codex 冒烟驱动修复。 注:happy-path 的"发送中"指示因延后翻转会晚一两拍,为有意取舍。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71a5386 commit b8d5f1e

7 files changed

Lines changed: 256 additions & 50 deletions

File tree

docs/exec-plans/active/post-0.55.1-issue-triage.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
| Phase | 内容 | 状态 | 备注 |
1111
|-------|------|------|------|
1212
| Phase 0 | Issue inventory + release/commit 对照 | ✅ 完成 | 覆盖 GitHub #606/#612/#613/#614/#615/#616/#617/#618/#619/#620/#621,以及 0.55 后更新的 #554/#577 |
13-
| Phase 1 | #615/#614 截图发送被吞 | 📋 待 Claude Code 修 | P0,源码已确认还有多条 no-send 成功返回路径会清空附件 |
14-
| Phase 2 | #620 Ollama 添加时要求安装 Claude Code | 📋 待 Claude Code 修 | P1,后端 provider test 不依赖 Claude Code,UI/语义提示疑似误导 |
15-
| Phase 3 | #613/#615 Codex/Opus 模型切换与模型列表不稳定 | 📋 待 Claude Code 深挖 | P1,本地 warm-cache 未复现;冷缓存/发现链路有源码风险 |
16-
| Phase 4 | #619 概率串会话 | 📋 待专项复现 | P1,高严重但当前未找到主路径 sessionId 分桶错误 |
17-
| Phase 5 | 低优先级/待补证队列 | 📋 待排期 | #612/#554/#617/#618/#606/#616/#621 |
13+
| Phase 1 | #615/#614 截图发送被吞 | ✅ 已实现(单测) | P0 已修:`onSend` 升级为可回传"未投递"的契约,MessageInput 所有带附件的 no-send 路径改走 `abortComposerSubmit`(首条 + 后续两条流都覆盖)。真实 UI smoke 待补 |
14+
| Phase 2 | #620 Ollama 添加时要求安装 Claude Code | ⏸ 本轮不做(用户 2026-06-08) | P1,方向较明确(语义/文案),但本轮先只做 P0;记录待排期 |
15+
| Phase 3 | #613/#615 Codex/Opus 模型切换与模型列表不稳定 | ⏸ 本轮不做(用户 2026-06-08) | P1,**未复现**,需冷缓存专项复现后再动,不盲改 |
16+
| Phase 4 | #619 概率串会话 | ⏸ 本轮不做(用户 2026-06-08) | P1,**未复现**,必须先按复现脚本坐实再碰 stream/ context,不盲改 |
17+
| Phase 5 | 低优先级/待补证队列 | ⏸ 本轮不做(用户 2026-06-08) | #612/#554/#617/#618/#606/#616/#621 |
1818

1919
## 用户会看到什么
2020

@@ -36,6 +36,8 @@
3636
- 2026-06-08:#615 拆成两个问题处理:截图/附件丢失为 P0,可从 `PromptInput` + `MessageInput` 清空契约直接证明;模型无法选中/列表不稳定并入 Phase 3。
3737
- 2026-06-08:#620 定为语义/UX P1,而不是“真实需要 Claude Code CLI”。后端 test provider 已明确绕过 Claude Code SDK subprocess。
3838
- 2026-06-08:#619 定为高严重待复现。主路径 stream subscription 按 `sessionId` 分桶,当前不能直接判定为全局广播 bug。
39+
- 2026-06-08:Claude Code 复核 triage——深挖确认 P0(#615)成立(MessageInput 多条裸 `return` no-send 路径 + `onSend``void` 导致 ChatView/首消息页的 provider 门早退后附件已被清空;首条与后续两条流都中招);spot-check Phase 2(Ollama)代码证据成立;Phase 3/4 认同"未复现、需专项复现再动"。
40+
- 2026-06-08:用户决定**本轮只修 P0(#615**,Phase 2/3/4/5 暂不做(不确定 / 待复现),保留本文档作记录。P0 已由 Claude Code 实现并通过单测;真实 UI smoke 待补。其余 Phase 待用户后续主动发起。
3941

4042
## Release / Commit 对照
4143

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* #615 — screenshots eaten on a no-op send. Generalizes the run-checkpoint
3+
* preservation contract: the composer must keep the user's text + attachments
4+
* whenever a submit is NOT actually delivered, not just on the checkpoint block.
5+
*
6+
* Two halves:
7+
* 1. MessageInput routes EVERY file-carrying no-send branch through
8+
* abortComposerSubmit() (which throws → PromptInput's reject branch keeps
9+
* text/files) instead of a bare `return` (which resolves → PromptInput
10+
* clears). The normal + badge sends now AWAIT onSend and abort when it
11+
* reports the send was gated.
12+
* 2. The onSend providers (ChatView.sendMessage for subsequent messages,
13+
* page.tsx sendFirstMessage for the first) RETURN false on every
14+
* provider/runtime gate, so MessageInput can tell a gated send from a real
15+
* one. Before this, onSend was `void` and those gates fired *after*
16+
* MessageInput had already let PromptInput clear the screenshot.
17+
*
18+
* Source-level pins (the send path is React-coupled; no renderer here). They
19+
* also guard against a future no-send branch being written as a bare `return`.
20+
*/
21+
22+
import { describe, it } from 'node:test';
23+
import assert from 'node:assert/strict';
24+
import { readFileSync } from 'node:fs';
25+
import path from 'node:path';
26+
27+
const read = (rel: string) => readFileSync(path.resolve(__dirname, '../..', rel), 'utf8');
28+
const countMatches = (src: string, re: RegExp) => (src.match(re) ?? []).length;
29+
30+
describe('MessageInput: no-send branches preserve the composer (#615)', () => {
31+
const src = read('components/chat/MessageInput.tsx');
32+
33+
it('has the shared abortComposerSubmit() throw helper', () => {
34+
assert.match(src, /function abortComposerSubmit\(reason: string\): never\s*\{\s*throw new Error\(reason\)/);
35+
});
36+
37+
it('awaits onSend and aborts when the send was not delivered (normal + badge paths)', () => {
38+
// Both file-carrying sends must await the delivery signal …
39+
assert.ok(
40+
countMatches(src, /const delivered = await onSend\(/g) >= 2,
41+
'both the normal and badge send paths must await onSend for the delivery signal',
42+
);
43+
// … and abort (preserve) when it comes back false.
44+
assert.ok(
45+
countMatches(src, /if \(delivered === false\) abortComposerSubmit\('composer-send-not-delivered'\)/g) >= 2,
46+
'each awaited send must preserve the composer when delivered === false',
47+
);
48+
});
49+
50+
it('badge-during-streaming and disabled-with-content preserve instead of bare return', () => {
51+
assert.match(src, /if \(isStreaming\) abortComposerSubmit\('composer-badge-streaming'\)/);
52+
assert.match(src, /if \(disabled\) abortComposerSubmit\('composer-disabled'\)/);
53+
});
54+
55+
it('the old screenshot-eating bare returns are gone (no `|| disabled) return;`)', () => {
56+
assert.doesNotMatch(src, /\|\| disabled\)\s*return;/);
57+
});
58+
});
59+
60+
describe('ChatView.sendMessage signals not-delivered on every provider gate (#615)', () => {
61+
const src = read('components/chat/ChatView.tsx');
62+
63+
it('provider-feed-loading gate returns false', () => {
64+
assert.match(src, /providerFetchState === 'idle'\)[\s\S]{0,200}return false/);
65+
});
66+
67+
it('no-compatible-provider gate returns false', () => {
68+
assert.match(src, /sendMessage suppressed: no provider compatible[\s\S]{0,120}return false/);
69+
});
70+
71+
it('runtime-incompatible gate returns false', () => {
72+
assert.match(src, /sendMessage suppressed: session provider not compatible[\s\S]{0,160}return false/);
73+
});
74+
});
75+
76+
describe('page.tsx sendFirstMessage signals not-delivered on its gates (#615)', () => {
77+
const src = read('app/chat/page.tsx');
78+
79+
it('model-not-ready gate returns false', () => {
80+
assert.match(src, /if \(!modelReady\) return false/);
81+
});
82+
83+
it('no-compatible-provider gate returns false', () => {
84+
assert.match(src, /if \(noCompatibleProvider\)[\s\S]{0,200}return false/);
85+
});
86+
87+
it('cannot-send-with-current-provider gate returns false', () => {
88+
assert.match(src, /if \(!canSendWithCurrentProvider\)[\s\S]{0,200}return false/);
89+
});
90+
91+
it('pre-delivery failure (session create / POST rejected) preserves the composer via the accepted flag (#615 Codex smoke)', () => {
92+
// Codex's real UI smoke: inject a 500 into POST /api/chat/sessions → error
93+
// banner shows, text stays, but the screenshot was eaten because the catch
94+
// only set a banner (no return false). The `accepted` flag fixes it: only
95+
// true once the backend accepts the message; the catch returns false otherwise.
96+
assert.match(src, /let accepted = false/);
97+
assert.match(src, /accepted = true/);
98+
assert.match(src, /if \(!accepted\) return false/);
99+
});
100+
101+
it('defers the isStreaming flip until after accept so a pre-accept failure does not remount the composer (#615 remount fix)', () => {
102+
// Root cause beyond the return value: the isNewChat ternary renders the
103+
// composer under two different parents (centered hero vs active layout).
104+
// Flipping isStreaming before accept swaps the parent → MessageInput remounts
105+
// → PromptInput loses the attachment BEFORE we learn the send failed. So the
106+
// layout-driving flip must happen only after `accepted = true`, guarded
107+
// against double-submit by an in-flight ref. (Source-pin only — the real
108+
// proof is Codex's inject-500 UI smoke; this just guards the structure.)
109+
assert.match(src, /const firstSendInFlightRef = useRef\(false\)/);
110+
assert.match(src, /if \(firstSendInFlightRef\.current\) return false/);
111+
assert.equal(countMatches(src, /setIsStreaming\(true\)/g), 1, 'isStreaming must be flipped in exactly one place (deferred to post-accept)');
112+
assert.match(src, /accepted = true;[\s\S]{0,400}setIsStreaming\(true\)/);
113+
});
114+
115+
it('composer-stack siblings are keyed so an ErrorBanner toggle keeps MessageInput identity (#615)', () => {
116+
assert.match(src, /<MessageInput\s+key="composer-message-input"/);
117+
});
118+
});

src/__tests__/unit/run-checkpoint-blocking.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ describe('MessageInput ↔ PromptInput checkpoint preservation contract', () =>
127127

128128
it('MessageInput throws the checkpoint-blocked sentinel instead of returning', () => {
129129
const src = read('components/chat/MessageInput.tsx');
130-
assert.match(src, /throw new Error\(['"]run-checkpoint-blocked['"]\)/);
130+
// #615 refactor: the raw throw moved into the shared abortComposerSubmit()
131+
// helper. Accept either form so the sentinel stays pinned through the move.
132+
assert.match(src, /(throw new Error|abortComposerSubmit)\(['"]run-checkpoint-blocked['"]\)/);
131133
assert.doesNotMatch(
132134
src,
133135
/blockingReasonIds[\s\S]{0,120}\{\s*return;\s*\}/,

src/app/chat/page.tsx

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ function NewChatPageInner() {
297297
}, []);
298298
const [createdSessionId, setCreatedSessionId] = useState<string | undefined>();
299299
const abortControllerRef = useRef<AbortController | null>(null);
300+
// #615: guards the first-message send while it's mid-flight. We defer the
301+
// isStreaming / optimistic-bubble flips until the backend ACCEPTS the message
302+
// (otherwise flipping `isNewChat` remounts the composer and eats the
303+
// screenshot), which means the usual `if (isStreaming) return` re-entry guard
304+
// isn't armed during that window — this ref blocks a double-submit instead.
305+
const firstSendInFlightRef = useRef(false);
300306
// Effort level — lifted here so the first message includes it
301307
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
302308
// Provider options (thinking mode + 1M context)
@@ -721,10 +727,13 @@ function NewChatPageInner() {
721727

722728
const sendFirstMessage = useCallback(
723729
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[], selectedSkills?: readonly string[]) => {
724-
if (isStreaming) return;
730+
// Each early-out below is a NOT-delivered case: return false so the
731+
// composer preserves the user's text + attachments instead of letting
732+
// PromptInput clear a first-message screenshot that never got sent (#615).
733+
if (isStreaming) return false;
725734

726735
// Wait for model/provider to be resolved from the global default before allowing send
727-
if (!modelReady) return;
736+
if (!modelReady) return false;
728737

729738
// Block send when the runtime-filtered API returned an empty group
730739
// list — user has providers but none are compatible with the
@@ -737,7 +746,7 @@ function NewChatPageInner() {
737746
message: t('error.providerUnavailable'),
738747
description: t('chat.empty.noProvider'),
739748
});
740-
return;
749+
return false; // not delivered → preserve composer (#615)
741750
}
742751

743752
// Phase 6 UI收口 P0 (2026-05-14): pinned-invalid is a GLOBAL
@@ -760,7 +769,7 @@ function NewChatPageInner() {
760769
// Require a project directory before sending
761770
if (!workingDir.trim()) {
762771
setErrorBanner({ message: t('chat.empty.noDirectory') });
763-
return;
772+
return false; // not delivered → preserve composer (#615)
764773
}
765774

766775
// Phase 6 P0 follow-up (2026-05-15) — Codex Account is a virtual
@@ -783,19 +792,28 @@ function NewChatPageInner() {
783792
message: t('error.providerUnavailable'),
784793
description: t('chat.empty.noProvider'),
785794
});
786-
return;
795+
return false; // not delivered → preserve composer (#615)
787796
}
788797

789-
setIsStreaming(true);
790-
setStreamingContent('');
791-
setToolUses([]);
792-
setToolResults([]);
793-
setStatusText(undefined);
798+
// #615 remount fix: do NOT flip isStreaming / push the optimistic bubble
799+
// yet. Either flips `isNewChat` (messages.length === 0 && !isStreaming),
800+
// which swaps the whole layout ternary — the composer moves from the
801+
// centered hero branch to the active-layout branch (a DIFFERENT parent), so
802+
// MessageInput remounts and PromptInput loses the attachment, BEFORE we even
803+
// learn the send failed. Defer those flips to the post-accept point so a
804+
// pre-acceptance failure leaves the hero (and the screenshot) untouched.
805+
if (firstSendInFlightRef.current) return false; // double-submit guard while mid-flight
806+
firstSendInFlightRef.current = true;
794807

795808
const controller = new AbortController();
796809
abortControllerRef.current = controller;
797810

798811
let sessionId = '';
812+
// #615: tracks whether the message reached a delivered / recoverable state
813+
// (session created + POST /api/chat accepted). A failure BEFORE this must
814+
// return false so the composer preserves the user's text + attachments —
815+
// otherwise a session-create 500 silently eats the screenshot.
816+
let accepted = false;
799817

800818
try {
801819
// Create a new session with working directory + model/provider
@@ -844,23 +862,10 @@ function NewChatPageInner() {
844862
// Notify ChatListPanel to refresh immediately
845863
window.dispatchEvent(new CustomEvent('session-created'));
846864

847-
// Add user message to UI — use displayOverride for chat bubble if provided
848-
const displayUserContent = displayOverride || content;
849-
// Optimistic save preserves base64 `data` so images can render
850-
// their thumbnail immediately (see ChatView for the full
851-
// explanation; backend still strips `data` before persistence).
852-
const contentWithFileMeta = files && files.length > 0
853-
? `<!--files:${JSON.stringify(files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data })))}-->${displayUserContent}`
854-
: displayUserContent;
855-
const userMessage: Message = {
856-
id: 'temp-' + Date.now(),
857-
session_id: session.id,
858-
role: 'user',
859-
content: contentWithFileMeta,
860-
created_at: new Date().toISOString(),
861-
token_usage: null,
862-
};
863-
setMessages([userMessage]);
865+
// NOTE: the optimistic user bubble is pushed AFTER the message is
866+
// accepted (post-accept block below), not here — pushing it now would
867+
// make messages non-empty → flip isNewChat → remount the composer and
868+
// eat the screenshot on a /api/chat rejection. (#615)
864869

865870
// Build thinking config from settings
866871
const thinkingConfig = thinkingMode && thinkingMode !== 'adaptive'
@@ -902,6 +907,37 @@ function NewChatPageInner() {
902907
}
903908
throw new Error(err?.error || 'Failed to send message');
904909
}
910+
// Backend accepted the message + files (POST /api/chat is 2xx and the
911+
// stream is opening) — from here the screenshot is committed
912+
// server-side, so a later error must NOT preserve the composer (#615).
913+
accepted = true;
914+
915+
// Flip the layout-driving state ONLY now: show streaming + push the
916+
// optimistic user bubble. Deferring to here keeps `isNewChat` true
917+
// through any pre-acceptance failure, so the composer never remounts and
918+
// the screenshot survives (#615).
919+
setIsStreaming(true);
920+
setStreamingContent('');
921+
setToolUses([]);
922+
setToolResults([]);
923+
setStatusText(undefined);
924+
{
925+
// Optimistic user bubble — preserves base64 `data` so images render
926+
// their thumbnail immediately (backend strips `data` before persisting).
927+
const displayUserContent = displayOverride || content;
928+
const contentWithFileMeta = files && files.length > 0
929+
? `<!--files:${JSON.stringify(files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data })))}-->${displayUserContent}`
930+
: displayUserContent;
931+
const userMessage: Message = {
932+
id: 'temp-' + Date.now(),
933+
session_id: session.id,
934+
role: 'user',
935+
content: contentWithFileMeta,
936+
created_at: new Date().toISOString(),
937+
token_usage: null,
938+
};
939+
setMessages([userMessage]);
940+
}
905941

906942
const reader = response.body?.getReader();
907943
if (!reader) throw new Error('No response stream');
@@ -1115,6 +1151,11 @@ function NewChatPageInner() {
11151151
const errMsg = error instanceof Error ? error.message : 'Unknown error';
11161152
setErrorBanner({ message: t('error.sessionCreateFailed'), description: errMsg });
11171153
}
1154+
// #615: a failure BEFORE the message was accepted for delivery (session
1155+
// creation or POST /api/chat rejected) must preserve the composer so the
1156+
// user's screenshot isn't cleared. Post-acceptance errors (mid-stream)
1157+
// keep today's behavior — the message already went, so the composer clears.
1158+
if (!accepted) return false;
11181159
} finally {
11191160
setIsStreaming(false);
11201161
setStreamingContent('');
@@ -1127,6 +1168,7 @@ function NewChatPageInner() {
11271168
setPermissionResolved(null);
11281169
setPendingApprovalSessionId('');
11291170
abortControllerRef.current = null;
1171+
firstSendInFlightRef.current = false;
11301172
}
11311173
},
11321174
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, canSendWithCurrentProvider, modelReady, noCompatibleProvider, invalidDefault]
@@ -1210,8 +1252,14 @@ function NewChatPageInner() {
12101252
// ChatComposerActionBar across two branches.
12111253
const composerStack = (
12121254
<>
1255+
{/* #615: stable keys so MessageInput keeps its identity (and PromptInput
1256+
keeps its attachment state) when ErrorBanner appears/disappears as a
1257+
sibling. The dominant remount cause — the isNewChat layout swap — is
1258+
fixed by deferring the layout-flip until accept (see sendFirstMessage);
1259+
these keys cover the within-parent ErrorBanner toggle. */}
12131260
{errorBanner && (
12141261
<ErrorBanner
1262+
key="composer-error-banner"
12151263
message={errorBanner.message}
12161264
description={errorBanner.description}
12171265
className="mx-4 mb-2"
@@ -1221,14 +1269,16 @@ function NewChatPageInner() {
12211269
]}
12221270
/>
12231271
)}
1224-
<RunCheckpoint reasons={checkpointReasons} className="mb-2" onAction={handleCheckpointAction} />
1272+
<RunCheckpoint key="composer-run-checkpoint" reasons={checkpointReasons} className="mb-2" onAction={handleCheckpointAction} />
12251273
<PermissionPrompt
1274+
key="composer-permission-prompt"
12261275
pendingPermission={pendingPermission}
12271276
permissionResolved={permissionResolved}
12281277
onPermissionResponse={handlePermissionResponse}
12291278
toolUses={toolUses}
12301279
/>
12311280
<MessageInput
1281+
key="composer-message-input"
12321282
onSend={sendFirstMessage}
12331283
onCommand={handleCommand}
12341284
onStop={stopStreaming}

0 commit comments

Comments
 (0)