mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Show web attachments in message context
This commit is contained in:
parent
10c69cec3f
commit
2c254339cf
11 changed files with 180 additions and 36 deletions
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue