Show web attachments in message context

This commit is contained in:
murashit 2026-07-14 10:37:29 +09:00
parent 10c69cec3f
commit 2c254339cf
11 changed files with 180 additions and 36 deletions

View file

@ -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),
});
}

View file

@ -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<string, ThreadStreamDialogueItem["contextAttachments"]>()
: 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<string, ThreadStreamDialogueItem["contextAttachments"]> {
const attachmentsByText = new Map<string, ThreadStreamDialogueItem["contextAttachments"]>();
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";
}

View file

@ -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;
}

View file

@ -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";

View file

@ -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;

View file

@ -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[] {

View file

@ -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 ? <EditedFiles view={view.metadata.editedFiles} context={context} /> : null}
{view.metadata.referencedThread ? <ReferencedThread reference={view.metadata.referencedThread} /> : null}
{view.metadata.mentionedFiles ? (
<MentionedFiles itemId={view.metadata.mentionedFiles.itemId} files={view.metadata.mentionedFiles.files} context={context} />
{view.metadata.contextItems ? (
<ContextItems itemId={view.metadata.contextItems.itemId} items={view.metadata.contextItems.items} context={context} />
) : null}
{view.metadata.autoReviewSummaries.length > 0 ? <AutoReviewSummaries summaries={[...view.metadata.autoReviewSummaries]} /> : null}
{view.metadata.systemDetails.length > 0 ? <SystemDetails details={view.metadata.systemDetails} /> : 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 (
<RememberedDetails
wrapperClassName="codex-panel__mentioned-files"
detailsClassName="codex-panel__mentioned-files-details"
detailsKey={`${itemId}:mentioned-files`}
wrapperClassName="codex-panel__context-items"
detailsClassName="codex-panel__context-items-details"
detailsKey={`${itemId}:context-items`}
summary={label}
context={context}
>
<ul>
{files.map((file) => (
<li key={`${file.name}\n${file.path}`}>
<span>{file.name}</span>
<span className="codex-panel__edited-files-separator"> · </span>
<span>{file.path}</span>
{items.map((item) => (
<li key={`${item.label}\n${item.detail ?? ""}`}>
<span>{item.label}</span>
{item.detail ? (
<>
<span className="codex-panel__edited-files-separator"> · </span>
<span>{item.detail}</span>
</>
) : null}
</li>
))}
</ul>

View file

@ -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));
}

View file

@ -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 = [

View file

@ -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<ThreadStreamItem, { dialogueKind: "user" }> {
return {
id,
kind: "dialogue",

View file

@ -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<HTMLElement>(".codex-panel__mentioned-files summary");
const summary = user.querySelector<HTMLElement>(".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<HTMLElement>(".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", () => {