diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index bcf54cf0..c2e5dae4 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -86,6 +86,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan actions: goals, }; const threadStreamPresenter = new ThreadStreamPresenter({ + panelId: environment.obsidian.viewId, obsidian: { app: environment.obsidian.app, owner: environment.obsidian.owner, diff --git a/src/features/chat/panel/surface/thread-stream-presenter.ts b/src/features/chat/panel/surface/thread-stream-presenter.ts index d46984f5..df47d8f0 100644 --- a/src/features/chat/panel/surface/thread-stream-presenter.ts +++ b/src/features/chat/panel/surface/thread-stream-presenter.ts @@ -65,6 +65,7 @@ interface ThreadStreamPresenterHistoryContext { } export interface ThreadStreamPresenterOptions { + panelId: string; obsidian: ThreadStreamPresenterObsidianContext; state: ThreadStreamPresenterStateContext; workspace: ThreadStreamPresenterWorkspaceContext; @@ -85,6 +86,7 @@ export class ThreadStreamPresenter { vaultPath: options.workspace.vaultPath, }); this.surfaceContext = { + panelId: options.panelId, vaultPath: options.workspace.vaultPath, setDisclosureOpen: (bucket, id, open) => { this.dispatch({ type: "ui/disclosure-set", bucket, id, open }); diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index fd9dccbb..a2b447ee 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -24,6 +24,7 @@ interface ChatThreadStreamRequests { } export interface ChatThreadStreamSurfaceContext { + panelId: string; vaultPath: string; setDisclosureOpen: (bucket: ThreadStreamDisclosureBucket, id: string, open: boolean) => void; setForkMenuItem: (itemId: string | null) => void; @@ -101,6 +102,7 @@ function threadStreamContextFromProjection( ...(pendingRequests ? { pendingRequests: { + controlNamespace: context.panelId, signature: pendingRequests.signature, snapshot: () => pendingRequests.snapshot, actions: context.requests.pendingActions, diff --git a/src/features/chat/ui/thread-stream/context.ts b/src/features/chat/ui/thread-stream/context.ts index 99535700..5e23fe0f 100644 --- a/src/features/chat/ui/thread-stream/context.ts +++ b/src/features/chat/ui/thread-stream/context.ts @@ -62,6 +62,7 @@ export interface TextItemContext extends TextItemContentContext, TextItemActionC export interface ThreadStreamContext extends ThreadStreamRenderContext, TextItemContext {} export interface PendingRequestBlockContext { + controlNamespace: string; signature: string; snapshot: () => PendingRequestBlockSnapshot; actions: () => PendingRequestBlockActions; diff --git a/src/features/chat/ui/thread-stream/pending-request-block.tsx b/src/features/chat/ui/thread-stream/pending-request-block.tsx index 64918f04..617bf9a8 100644 --- a/src/features/chat/ui/thread-stream/pending-request-block.tsx +++ b/src/features/chat/ui/thread-stream/pending-request-block.tsx @@ -25,6 +25,7 @@ export function pendingRequestBlockNode( autoFocusRequested = false, consumeAutoFocus?: () => boolean, autoFocusSignature = "", + controlNamespace = "codex-panel", ): UiNode { return ( ); } @@ -53,6 +55,7 @@ function PendingRequestBlock({ autoFocusRequested, consumeAutoFocus, autoFocusSignature, + controlNamespace, }: { approvals: readonly PendingApprovalViewModel[]; pendingUserInputs: readonly PendingUserInputViewModel[]; @@ -64,6 +67,7 @@ function PendingRequestBlock({ autoFocusRequested: boolean; consumeAutoFocus: (() => boolean) | undefined; autoFocusSignature: string; + controlNamespace: string; }): UiNode { const requestRef = useRef(null); useLayoutEffect(() => { @@ -80,7 +84,13 @@ function PendingRequestBlock({ ))} {pendingUserInputs.map((input) => ( - + ))} {pendingMcpElicitations.map((elicitation) => ( ))} @@ -98,10 +109,12 @@ function McpElicitationCard({ elicitation, mcpElicitationDrafts, actions, + controlNamespace, }: { elicitation: PendingMcpElicitationViewModel; mcpElicitationDrafts: ReadonlyMap; actions: PendingRequestBlockActions; + controlNamespace: string; }): UiNode { const formRef = useRef(null); const accept = () => { @@ -126,7 +139,12 @@ function McpElicitationCard({ accept(); }} > - + )} @@ -215,17 +233,19 @@ function UserInputCard({ input, userInputDrafts, actions, + controlNamespace, }: { input: PendingUserInputViewModel; userInputDrafts: ReadonlyMap; actions: PendingRequestBlockActions; + controlNamespace: string; }): UiNode { return (
{input.title}
{input.body}
- +
; actions: PendingRequestBlockActions; + controlNamespace: string; }): UiNode { return ( <> @@ -272,7 +294,7 @@ function UserInputQuestions({ {question.options && question.options.length > 0 ? ( <> {question.options.map((option) => { - const groupName = userInputRadioGroupName(input.requestId, question.id); + const groupName = userInputRadioGroupName(controlNamespace, input.requestId, question.id); return (
)} {field.description ?
{field.description}
: null} - + ); } @@ -373,12 +406,14 @@ function McpElicitationFieldControl({ actions, controlId, labelId, + controlNamespace, }: { field: PendingMcpElicitationFieldViewModel; current: string; actions: PendingRequestBlockActions; controlId: string; labelId: string; + controlNamespace: string; }): UiNode { switch (field.type) { case "boolean": @@ -404,7 +439,7 @@ function McpElicitationFieldControl({ { @@ -494,8 +529,12 @@ function selectedMcpElicitationValues(draft: string): Set { return new Set(); } -function userInputRadioGroupName(requestId: PendingUserInputViewModel["requestId"], questionId: string): string { - return `codex-panel-${String(requestId)}-${questionId}`; +function userInputRadioGroupName(namespace: string, requestId: PendingUserInputViewModel["requestId"], questionId: string): string { + return controlName(namespace, "user-input", typeof requestId, String(requestId), questionId); +} + +function controlName(namespace: string, ...parts: string[]): string { + return ["codex-panel", namespace, ...parts].map(encodeURIComponent).join("-"); } function OtherUserInputOption({ diff --git a/src/features/chat/ui/thread-stream/stream-blocks.tsx b/src/features/chat/ui/thread-stream/stream-blocks.tsx index 2d650bec..8b4dcb5d 100644 --- a/src/features/chat/ui/thread-stream/stream-blocks.tsx +++ b/src/features/chat/ui/thread-stream/stream-blocks.tsx @@ -71,6 +71,7 @@ function presentationBlockNode(block: ThreadStreamViewBlock, context: ThreadStre false, pendingRequests.consumeAutoFocus, block.signature, + pendingRequests.controlNamespace, ); } return streamItemNode(block, context); diff --git a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts index 7e20410a..349b03f2 100644 --- a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts @@ -553,6 +553,7 @@ function testThreadStreamSurfaceContext(options: { dispatch: (action: ChatAction) => void; }): ChatThreadStreamSurfaceContext { return { + panelId: "test-panel", vaultPath: options.vaultPath, setDisclosureOpen: (bucket, id, open) => { options.dispatch({ type: "ui/disclosure-set", bucket, id, open }); @@ -680,6 +681,7 @@ function threadStreamPresenter( const files = new Map(vaultFiles.map((path) => [path, tFile(path)])); const scrollPortBinding = createChatThreadStreamScrollBinding(); const presenter = new ThreadStreamPresenter({ + panelId: "test-panel", obsidian: { app: { workspace: { diff --git a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx index b2615e6b..b27d357e 100644 --- a/tests/features/chat/ui/thread-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/thread-stream/pending-requests.test.tsx @@ -28,6 +28,34 @@ import { } from "./test-helpers"; describe("pending request renderer decisions", () => { + it("keeps radio groups separate across panels with the same request id", () => { + const first = document.createElement("div"); + const second = document.createElement("div"); + const input = pendingUserInput(); + const render = (parent: HTMLElement, namespace: string) => + renderPendingRequestNode( + parent, + [], + [input], + { values: new Map() }, + new Set(), + pendingRequestActions(), + false, + undefined, + "", + namespace, + ); + + render(first, "panel-a"); + render(second, "panel-b"); + + const firstName = first.querySelector(".codex-panel__user-input-radio")?.name; + const secondName = second.querySelector(".codex-panel__user-input-radio")?.name; + expect(firstName).toContain("panel-a"); + expect(secondName).toContain("panel-b"); + expect(firstName).not.toBe(secondName); + }); + it("renders pending requests as one thread stream block and keeps user input drafts live", () => { const parent = document.createElement("div"); const drafts = new Map(); @@ -503,6 +531,7 @@ describe("pending request renderer decisions", () => { expect(parent.querySelector(".codex-panel__pending-request-title")?.textContent).toBe("MCP request from github"); const input = expectPresent(parent.querySelector(".codex-panel__mcp-elicitation-input")); const label = expectPresent(parent.querySelector(".codex-panel__mcp-elicitation-label")); + expect(label.id).toContain("test-panel"); expect(label.tagName).toBe("LABEL"); expect(label.getAttribute("for")).toBe(input.id); changeInputValue(input, "Updated"); @@ -681,6 +710,7 @@ function pendingRequestContext(options: { const snapshotFn = typeof snapshot === "function" ? (snapshot as () => PendingRequestBlockSnapshot) : () => snapshot ?? emptyPendingRequestBlockSnapshot(); return { + controlNamespace: "test-panel", signature: options.signature, snapshot: snapshotFn, actions: () => options.actions ?? pendingRequestActions(), diff --git a/tests/features/chat/ui/thread-stream/stream-items.test.tsx b/tests/features/chat/ui/thread-stream/stream-items.test.tsx index a2e46939..c83af2a7 100644 --- a/tests/features/chat/ui/thread-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/thread-stream/stream-items.test.tsx @@ -341,6 +341,7 @@ describe("thread stream item renderer decisions", () => { }, ], pendingRequests: { + controlNamespace: "test-panel", signature: "request:1", snapshot: () => ({ approvals: [], diff --git a/tests/features/chat/ui/thread-stream/test-helpers.tsx b/tests/features/chat/ui/thread-stream/test-helpers.tsx index a4478bec..da80af3b 100644 --- a/tests/features/chat/ui/thread-stream/test-helpers.tsx +++ b/tests/features/chat/ui/thread-stream/test-helpers.tsx @@ -233,6 +233,7 @@ export function renderPendingRequestNode( autoFocusRequested = false, consumeAutoFocus?: () => boolean, autoFocusSignature = "", + controlNamespace = "test-panel", ): void { const snapshot = pendingRequestBlockSnapshotFromState({ approvals, @@ -255,6 +256,7 @@ export function renderPendingRequestNode( autoFocusRequested, consumeAutoFocus, autoFocusSignature, + controlNamespace, ), ); }