From 2c254339cfcf33cfe0f35a4f46939f10e8aca591 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 14 Jul 2026 10:37:29 +0900 Subject: [PATCH] Show web attachments in message context --- .../turns/optimistic-turn-start.ts | 5 +++ .../completed-turn-reconciliation.ts | 36 +++++++++++++++++-- .../format/context-attachments.ts | 18 ++++++++++ .../chat/domain/thread-stream/items.ts | 6 ++++ .../host/obsidian/web-context.obsidian.ts | 3 +- .../presentation/thread-stream/text-view.ts | 22 +++++++----- src/features/chat/ui/thread-stream/text.tsx | 34 ++++++++++-------- src/styles/23-chat-thread-stream.css | 8 ++--- .../turns/optimistic-turn-start.test.ts | 18 ++++++++++ .../completed-turn-reconciliation.test.ts | 36 ++++++++++++++++++- .../blocks-and-messages.test.tsx | 30 ++++++++++++++-- 11 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 src/features/chat/domain/thread-stream/format/context-attachments.ts diff --git a/src/features/chat/application/turns/optimistic-turn-start.ts b/src/features/chat/application/turns/optimistic-turn-start.ts index 2ba5938f..7222cf8a 100644 --- a/src/features/chat/application/turns/optimistic-turn-start.ts +++ b/src/features/chat/application/turns/optimistic-turn-start.ts @@ -1,4 +1,5 @@ import type { CodexInput } from "../../../../domain/chat/input"; +import { contextAttachmentsFromInput } from "../../domain/thread-stream/format/context-attachments"; import { fileMentionsFromInput } from "../../domain/thread-stream/format/file-mentions"; import { userMessageDisplayText } from "../../domain/thread-stream/format/user-message-text"; import type { ThreadStreamDialogueItem, ThreadStreamFileMention, ThreadStreamItem } from "../../domain/thread-stream/items"; @@ -14,6 +15,7 @@ interface LocalUserDialogueParams { turnId?: string; referencedThread?: ThreadStreamDialogueItem["referencedThread"]; mentionedFiles?: readonly ThreadStreamFileMention[]; + contextAttachments?: ThreadStreamDialogueItem["contextAttachments"]; } export interface OptimisticTurnStartAckParams { @@ -49,6 +51,7 @@ export interface FailedTurnStartCleanupParams { function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDialogueItem { const mentionedFiles = params.mentionedFiles ?? []; + const contextAttachments = params.contextAttachments ?? []; return { id: params.id, kind: "dialogue", @@ -60,6 +63,7 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia ...(params.turnId ? { turnId: params.turnId } : {}), ...(params.referencedThread ? { referencedThread: params.referencedThread } : {}), ...(mentionedFiles.length > 0 ? { mentionedFiles: [...mentionedFiles] } : {}), + ...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}), }; } @@ -80,6 +84,7 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu ...(params.turnId ? { turnId: params.turnId } : {}), ...(params.referencedThread ? { referencedThread: params.referencedThread } : {}), mentionedFiles: fileMentionsFromInput([...params.codexInput]), + contextAttachments: contextAttachmentsFromInput(params.codexInput), }); } diff --git a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts index 6b47acb0..f2cadc23 100644 --- a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts @@ -12,7 +12,26 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn const { currentItems, completedTurnId, turnItems } = input; if (turnItems.length === 0) return currentItems; - const serverUserDialogues = turnItems.filter(isUserDialogue); + const contextAttachmentsByClientId = new Map( + currentItems.flatMap((item) => + isUserDialogue(item) && isLocalUserDialogueId(item.id) && item.contextAttachments + ? [[item.id, item.contextAttachments] as const] + : [], + ), + ); + const serverHasUserDialogueClientIds = turnItems.some((item) => isUserDialogue(item) && item.clientId); + const contextAttachmentsByFallbackText = serverHasUserDialogueClientIds + ? new Map() + : fallbackContextAttachmentsByText(currentItems, completedTurnId); + const turnItemsWithLocalContext = turnItems.map((item) => { + if (!isUserDialogue(item)) return item; + const contextAttachments = item.clientId + ? contextAttachmentsByClientId.get(item.clientId) + : contextAttachmentsByFallbackText.get(item.copyText ?? item.text); + return contextAttachments ? { ...item, contextAttachments } : item; + }); + + const serverUserDialogues = turnItemsWithLocalContext.filter(isUserDialogue); const serverUserDialogueClientIds = new Set(serverUserDialogues.map((item) => item.clientId).filter(isString)); const serverUserDialoguesByClientId = new Map( serverUserDialogues.flatMap((item) => (item.clientId ? ([[item.clientId, item]] as const) : [])), @@ -28,7 +47,7 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn .filter( (item) => !isReconciledOptimisticUserDialogue(item, completedTurnId, serverUserDialogueClientIds, serverUserDialogueFallbackTexts), ); - for (const item of turnItems) { + for (const item of turnItemsWithLocalContext) { mergedTurnItems = upsertThreadStreamItemById(mergedTurnItems, item); } @@ -40,6 +59,19 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn return [...retainedItems, ...mergedTurnItems]; } +function fallbackContextAttachmentsByText( + currentItems: readonly ThreadStreamItem[], + completedTurnId: string, +): Map { + const attachmentsByText = new Map(); + for (const item of currentItems) { + if (!isUserDialogue(item) || !isLocalUserDialogueId(item.id) || !item.contextAttachments) continue; + if (item.turnId && item.turnId !== completedTurnId) continue; + attachmentsByText.set(item.copyText ?? item.text, item.contextAttachments); + } + return attachmentsByText; +} + function isUserDialogue(item: ThreadStreamItem): item is ThreadStreamDialogueItem & { role: "user" } { return item.kind === "dialogue" && item.role === "user"; } diff --git a/src/features/chat/domain/thread-stream/format/context-attachments.ts b/src/features/chat/domain/thread-stream/format/context-attachments.ts new file mode 100644 index 00000000..5f83bddd --- /dev/null +++ b/src/features/chat/domain/thread-stream/format/context-attachments.ts @@ -0,0 +1,18 @@ +import type { CodexInputItem } from "../../../../../domain/chat/input"; +import type { ThreadStreamContextAttachment } from "../items"; + +export const WEB_CONTEXT_KEY = "codex_panel_web_context"; + +export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] { + return input.flatMap((item) => { + if (item.type !== "additionalContext" || item.key !== WEB_CONTEXT_KEY) return []; + const source = webContextSource(item.value); + return [{ label: "Web page", ...(source ? { detail: source } : {}) }]; + }); +} + +function webContextSource(value: string): string | null { + const sourceLine = value.split("\n").find((line) => line.startsWith("Source:")); + const source = sourceLine?.slice("Source:".length).trim() ?? ""; + return source || null; +} diff --git a/src/features/chat/domain/thread-stream/items.ts b/src/features/chat/domain/thread-stream/items.ts index 232f0657..a6b69993 100644 --- a/src/features/chat/domain/thread-stream/items.ts +++ b/src/features/chat/domain/thread-stream/items.ts @@ -123,6 +123,7 @@ interface ThreadStreamDialogueBase extends ThreadStreamBase { readonly copyText?: string; readonly referencedThread?: ReferencedThreadMetadata; readonly mentionedFiles?: readonly ThreadStreamFileMention[]; + readonly contextAttachments?: readonly ThreadStreamContextAttachment[]; } interface UserThreadStreamDialogueItem extends ThreadStreamDialogueBase { @@ -153,6 +154,11 @@ export interface ThreadStreamFileMention { readonly path: string; } +export interface ThreadStreamContextAttachment { + readonly label: string; + readonly detail?: string; +} + interface SystemThreadStreamItem extends ThreadStreamBase { readonly kind: "system"; readonly role: "system"; diff --git a/src/features/chat/host/obsidian/web-context.obsidian.ts b/src/features/chat/host/obsidian/web-context.obsidian.ts index 73659250..c2684ae0 100644 --- a/src/features/chat/host/obsidian/web-context.obsidian.ts +++ b/src/features/chat/host/obsidian/web-context.obsidian.ts @@ -4,8 +4,7 @@ import { htmlToMarkdown, requestUrl } from "obsidian"; import type { CodexInput } from "../../../../domain/chat/input"; import { codexTextInputWithAttachments } from "../../../../domain/chat/input"; import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot"; - -const WEB_CONTEXT_KEY = "codex_panel_web_context"; +import { WEB_CONTEXT_KEY } from "../../domain/thread-stream/format/context-attachments"; export interface WebUrlInput { text: string; diff --git a/src/features/chat/presentation/thread-stream/text-view.ts b/src/features/chat/presentation/thread-stream/text-view.ts index bd72fd4c..0d62bf76 100644 --- a/src/features/chat/presentation/thread-stream/text-view.ts +++ b/src/features/chat/presentation/thread-stream/text-view.ts @@ -24,9 +24,9 @@ export interface ReferencedThreadTextView { turnLimit: number; } -export interface MentionedFileTextView { - name: string; - path: string; +export interface ContextItemTextView { + label: string; + detail?: string; } export interface EditedFilesTextView { @@ -46,9 +46,9 @@ export interface TextItemDetailSectionView { interface ThreadStreamTextMetadataView { editedFiles?: EditedFilesTextView; referencedThread?: ReferencedThreadTextView; - mentionedFiles?: { + contextItems?: { itemId: string; - files: readonly MentionedFileTextView[]; + items: readonly ContextItemTextView[]; }; autoReviewSummaries: readonly string[]; systemDetails: readonly TextItemDetailSectionView[]; @@ -118,7 +118,7 @@ function textMetadataView(item: ThreadStreamItem, annotations?: ThreadStreamItem return { ...definedProp("editedFiles", editedFilesView(item, annotations)), ...definedProp("referencedThread", referencedThreadView(item)), - ...definedProp("mentionedFiles", mentionedFilesView(item)), + ...definedProp("contextItems", contextItemsView(item)), autoReviewSummaries: item.kind === "dialogue" ? (annotations?.autoReviewSummaries ?? []) : [], systemDetails: item.kind === "system" ? systemDetailViews(item.noticeSections ?? []) : [], userInputDetails: item.kind === "userInputResult" ? userInputQuestionDetailViews(item.questions) : [], @@ -138,9 +138,13 @@ function referencedThreadView(item: ThreadStreamItem): ReferencedThreadTextView return item.referencedThread; } -function mentionedFilesView(item: ThreadStreamItem): ThreadStreamTextMetadataView["mentionedFiles"] | undefined { - if (item.kind !== "dialogue" || !item.mentionedFiles || item.mentionedFiles.length === 0) return undefined; - return { itemId: item.id, files: item.mentionedFiles }; +function contextItemsView(item: ThreadStreamItem): ThreadStreamTextMetadataView["contextItems"] | undefined { + if (item.kind !== "dialogue") return undefined; + const items = [ + ...(item.mentionedFiles ?? []).map((file) => ({ label: file.name, detail: file.path })), + ...(item.contextAttachments ?? []), + ]; + return items.length > 0 ? { itemId: item.id, items } : undefined; } function systemDetailViews(sections: readonly ThreadStreamNoticeSection[]): readonly TextItemDetailSectionView[] { diff --git a/src/features/chat/ui/thread-stream/text.tsx b/src/features/chat/ui/thread-stream/text.tsx index cfeeef2b..8a02f644 100644 --- a/src/features/chat/ui/thread-stream/text.tsx +++ b/src/features/chat/ui/thread-stream/text.tsx @@ -2,8 +2,8 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { IconButton } from "../../../../shared/obsidian/components.obsidian"; import type { + ContextItemTextView, EditedFilesTextView, - MentionedFileTextView, ReferencedThreadTextView, TextItemDetailSectionView, ThreadStreamTextView, @@ -27,8 +27,8 @@ function Text({ view, context }: { view: ThreadStreamTextView; context: TextItem )} {view.metadata.editedFiles ? : null} {view.metadata.referencedThread ? : null} - {view.metadata.mentionedFiles ? ( - + {view.metadata.contextItems ? ( + ) : null} {view.metadata.autoReviewSummaries.length > 0 ? : null} {view.metadata.systemDetails.length > 0 ? : null} @@ -189,30 +189,34 @@ function EditedFiles({ view, context }: { view: EditedFilesTextView; context: Te ); } -function MentionedFiles({ +function ContextItems({ itemId, - files, + items, context, }: { itemId: string; - files: readonly MentionedFileTextView[]; + items: readonly ContextItemTextView[]; context: TextItemDetailStateContext; }): UiNode { - const label = `Context · ${String(files.length)} ${files.length === 1 ? "item" : "items"}`; + const label = `Context · ${String(items.length)} ${items.length === 1 ? "item" : "items"}`; return (
    - {files.map((file) => ( -
  • - {file.name} - · - {file.path} + {items.map((item) => ( +
  • + {item.label} + {item.detail ? ( + <> + · + {item.detail} + + ) : null}
  • ))}
diff --git a/src/styles/23-chat-thread-stream.css b/src/styles/23-chat-thread-stream.css index c150034f..c121857e 100644 --- a/src/styles/23-chat-thread-stream.css +++ b/src/styles/23-chat-thread-stream.css @@ -325,7 +325,7 @@ .codex-panel__edited-files, .codex-panel__referenced-thread, -.codex-panel__mentioned-files, +.codex-panel__context-items, .codex-panel__auto-reviews { margin-top: var(--codex-panel-item-gap); color: var(--text-muted); @@ -346,7 +346,7 @@ display: block; } -.codex-panel__mentioned-files-details { +.codex-panel__context-items-details { display: block; } @@ -357,13 +357,13 @@ } .codex-panel__edited-files-details > summary, -.codex-panel__mentioned-files-details > summary, +.codex-panel__context-items-details > summary, .codex-panel__auto-reviews > summary { cursor: default; } .codex-panel__edited-files-details > summary:hover, -.codex-panel__mentioned-files-details > summary:hover, +.codex-panel__context-items-details > summary:hover, .codex-panel__auto-reviews > summary:hover { color: var(--nav-item-color-hover, var(--text-normal)); } diff --git a/tests/features/chat/application/turns/optimistic-turn-start.test.ts b/tests/features/chat/application/turns/optimistic-turn-start.test.ts index d2de1433..c4aa1411 100644 --- a/tests/features/chat/application/turns/optimistic-turn-start.test.ts +++ b/tests/features/chat/application/turns/optimistic-turn-start.test.ts @@ -55,6 +55,24 @@ describe("optimistic turn start helpers", () => { }); }); + it("keeps web context visible as user message attachment metadata", () => { + const text = "https://example.com/ summarize this"; + const input = [ + { type: "text" as const, text }, + { + type: "additionalContext" as const, + key: "codex_panel_web_context", + kind: "untrusted" as const, + value: "Web page context for the current user input:\nSource: https://example.com/\nTitle: Example\n\nReadable article", + }, + ]; + + expect(localUserDialogueItemFromInput({ id: "local-user", text, codexInput: input })).toMatchObject({ + text, + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + }); + }); + it("keeps active file mentions visible even when the same file is mentioned explicitly", () => { const text = "Read [[Note]]."; const input = [ diff --git a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts index c46d5086..839b1abe 100644 --- a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts +++ b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts @@ -21,6 +21,40 @@ describe("reconcileCompletedTurnItems", () => { expect(next.map((item) => item.id)).toEqual(["local-user-2", "u1", "u2", "a1"]); }); + it("keeps local context attachment metadata when replacing an optimistic user dialogue", () => { + const optimistic = { + ...userDialogue("local-user-1", "https://example.com/ summarize this", "turn", "local-user-1"), + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + } satisfies ThreadStreamItem; + const server = userDialogue("u1", "https://example.com/ summarize this", "turn", "local-user-1"); + + const next = reconcileCompletedTurnItems({ currentItems: [optimistic], completedTurnId: "turn", turnItems: [server] }); + + expect(next).toEqual([ + expect.objectContaining({ + id: "u1", + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + }), + ]); + }); + + it("keeps local context attachment metadata when reconciling by text without client ids", () => { + const optimistic = { + ...userDialogue("local-user-1", "https://example.com/ summarize this", "turn"), + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + } satisfies ThreadStreamItem; + const server = userDialogue("u1", "https://example.com/ summarize this", "turn"); + + const next = reconcileCompletedTurnItems({ currentItems: [optimistic], completedTurnId: "turn", turnItems: [server] }); + + expect(next).toEqual([ + expect.objectContaining({ + id: "u1", + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + }), + ]); + }); + it("falls back to local user text only when server user dialogues have no client ids", () => { const currentItems: ThreadStreamItem[] = [ userDialogue("local-user-without-client-id", "fallback text", "turn"), @@ -88,7 +122,7 @@ function currentIds(items: readonly ThreadStreamItem[]): string { return items.map((item) => item.id).join(","); } -function userDialogue(id: string, text: string, turnId: string, clientId?: string): ThreadStreamItem { +function userDialogue(id: string, text: string, turnId: string, clientId?: string): Extract { return { id, kind: "dialogue", diff --git a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index bf447329..4e027433 100644 --- a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -1208,13 +1208,37 @@ describe("thread stream rendering and action menu", () => { }); const user = renderThreadStreamBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); - const summary = user.querySelector(".codex-panel__mentioned-files summary"); + const summary = user.querySelector(".codex-panel__context-items summary"); expect(user.querySelector(".codex-panel__stream-item-content")?.textContent).toBe("Read [[Alpha]]."); expect(summary?.textContent).toBe("Context · 1 item"); expect(summary?.tabIndex).toBe(-1); - expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("Alpha"); - expect(user.querySelector(".codex-panel__mentioned-files")?.textContent).toContain("thoughts/Alpha.md"); + expect(user.querySelector(".codex-panel__context-items")?.textContent).toContain("Alpha"); + expect(user.querySelector(".codex-panel__context-items")?.textContent).toContain("thoughts/Alpha.md"); + }); + + it("includes web attachments in the collapsed user dialogue context", () => { + const blocks = threadStreamBlocks({ + items: [ + { + id: "u1", + kind: "dialogue", + dialogueKind: "user", + role: "user", + text: "https://example.com/ summarize this", + copyText: "https://example.com/ summarize this", + mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], + contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], + }, + ], + }); + + const user = renderThreadStreamBlockElement(expectPresent(blocks.find((block) => block.key === "item:u1"))); + const summary = user.querySelector(".codex-panel__context-items summary"); + + expect(summary?.textContent).toBe("Context · 2 items"); + expect(user.querySelector(".codex-panel__context-items")?.textContent).toContain("Web page"); + expect(user.querySelector(".codex-panel__context-items")?.textContent).toContain("https://example.com/"); }); it("does not render the open diff action without aggregated turn diff", () => {