Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions app/api/sessions/[id]/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export async function GET(
) {
const { id } = await params;
const url = new URL(req.url);
const leafId = url.searchParams.get("leafId") ?? undefined;
const leafId = url.searchParams.has("leafId")
? url.searchParams.get("leafId") || null
: undefined;
const deferThinking = url.searchParams.has("deferThinking");
const deferToolResultImages = url.searchParams.has("deferMedia");
// `tail` caps the ancestor chain returned (default 50); `before` rewinds the
Expand All @@ -30,7 +32,8 @@ export async function GET(
const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(filePath!);
// `before` is the oldest entry already on the client; fetch its ancestors
// only (excludeLeaf) so prepending the page does not duplicate `before`.
const context = buildSessionContext(sm.getEntries() as never, before ?? leafId, {
const contextLeafId = before !== undefined ? before : leafId;
const context = buildSessionContext(sm.getEntries() as never, contextLeafId, {
deferThinking,
deferToolResultImages,
tail,
Expand Down
8 changes: 7 additions & 1 deletion app/api/sessions/context-route.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ const { buildSessionContext } = await jiti.import("@/lib/session-reader");
test("context route parses ?tail and ?before, excluding the boundary on paging", () => {
assert.match(routeSrc, /const tail = Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/);
assert.match(routeSrc, /const before = url\.searchParams\.get\("before"\)/);
assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, before \?\? leafId, \{[^}]*excludeLeaf: Boolean\(before\)/);
assert.match(routeSrc, /const contextLeafId = before !== undefined \? before : leafId/);
assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, contextLeafId, \{[^}]*excludeLeaf: Boolean\(before\)/);
});

test("context route distinguishes an explicit root from an omitted leaf", () => {
assert.match(routeSrc, /url\.searchParams\.has\("leafId"\)/);
assert.match(routeSrc, /url\.searchParams\.get\("leafId"\) \|\| null/);
});

test("context route: ?before pages upward without duplicating the boundary", () => {
Expand Down
49 changes: 35 additions & 14 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks, splitThinkingBlocks } from "@/lib/message-display";
import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files";
import { MessageView, ThinkingBlock } from "./MessageView";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import {
ChatInput,
getUserMessageDraftImages,
getUserMessageText,
type ChatInputHandle,
} from "./ChatInput";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
import { ExtensionStatusBar } from "./ExtensionStatusBar";
import { AnsiText } from "./AnsiText";
Expand Down Expand Up @@ -249,11 +254,6 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi
onAgentEnd?.();
}, [completionNotificationsEnabled, onAgentEnd]);

// 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染
const handleEditContent = useCallback((message: UserMessage) => {
chatInputRef?.current?.replaceMessage(message);
}, [chatInputRef]);

const {
loading, error, messages, entryIds, historyCursor, hasEarlierMessages, streamState,
agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel,
Expand All @@ -266,7 +266,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi
isNew,
sessionIdRef, messagesEndRef, scrollContainerRef,
lastUserMsgRef, promptAnchorActive,
handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
handleSend, handleAbort, handleFork, handleEditAndSend, handleModelChange,
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
handleRecallQueue,
handleBuiltinSlashCommand,
Expand All @@ -277,6 +277,30 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi
modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen,
});
const sessionBusy = agentRunning || bashRunning;
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);

useEffect(() => {
setEditingEntryId(null);
}, [session?.id, newSessionDraftKey]);

useEffect(() => {
if (sessionBusy) setEditingEntryId(null);
}, [sessionBusy]);

const handleStartEdit = useCallback((entryId: string) => {
setEditingEntryId(entryId);
}, []);

const handleCancelEdit = useCallback(() => {
setEditingEntryId(null);
}, []);

const handleSubmitEdit = useCallback(async (entryId: string, message: UserMessage): Promise<boolean> => {
const images = getUserMessageDraftImages(message);
const submitted = await handleEditAndSend(entryId, getUserMessageText(message), images);
if (submitted) setEditingEntryId(null);
return submitted;
}, [handleEditAndSend]);

useEffect(() => {
if (
Expand Down Expand Up @@ -743,10 +767,6 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi

const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[] } = {}): ReactNode => {
const msg = options.messageOverride ?? messages[idx];
const prevAssistantEntryId =
msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant"
? entryIds[idx - 1]
: undefined;
const isVisible = isMessageGroupAnchor(msg) || msg.role === "assistant";
const currentRefIdx = visibleRefIndexByMessage.get(idx);
const keyPrefix = options.keyPrefix ?? "message";
Expand Down Expand Up @@ -778,9 +798,10 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi
searchBlock={entryIds[idx] === pendingSearchScroll?.entryId ? searchBlock : undefined}
onFork={sessionBusy || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork}
forking={forkingEntryId === entryIds[idx]}
onNavigate={sessionBusy ? undefined : handleNavigate}
prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId}
onEditContent={handleEditContent}
editing={msg.role === "user" && editingEntryId === entryIds[idx]}
onStartEdit={!sessionBusy && !isNew ? handleStartEdit : undefined}
onCancelEdit={handleCancelEdit}
onSubmitEdit={handleSubmitEdit}
showTimestamp={showTimestamp}
prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined}
sessionId={session?.id ?? sessionIdRef.current ?? undefined}
Expand Down
42 changes: 42 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,48 @@ test("keeps attached images when restoring a compact command for editing", () =>
]);
});

test("renders an inline edit action for the first user message", () => {
const html = renderMessage({
role: "user",
content: "original request",
}, {
entryId: "user-root",
onStartEdit() {},
async onSubmitEdit() { return true; },
});

assert.match(html, /aria-label="Edit message"/);
assert.ok(html.indexOf("Copy message") < html.indexOf("Edit message"));
});

test("renders the message text with Cancel and Send inside the inline editor", () => {
const html = renderMessage({
role: "user",
content: "original request",
}, {
entryId: "user-2",
editing: true,
onStartEdit() {},
onCancelEdit() {},
async onSubmitEdit() { return true; },
});

assert.match(html, /<textarea[^>]+aria-label="Edit message"/);
assert.match(html, />original request<\/textarea>/);
assert.match(html, />Cancel<\/button>/);
assert.match(html, />Send<\/button>/);
assert.doesNotMatch(html, /title="Copy message"/);
});

test("does not offer message editing without an idle edit handler", () => {
const html = renderMessage({
role: "user",
content: "running request",
}, { entryId: "user-running" });

assert.doesNotMatch(html, /aria-label="Edit message"/);
});

test("renders user-message images as buttons that open a larger preview", () => {
const html = renderMessage({
role: "user",
Expand Down
Loading