mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Namespace pending request controls by chat panel
This commit is contained in:
parent
75bf1a0fb8
commit
d417157162
10 changed files with 95 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export function pendingRequestBlockNode(
|
|||
autoFocusRequested = false,
|
||||
consumeAutoFocus?: () => boolean,
|
||||
autoFocusSignature = "",
|
||||
controlNamespace = "codex-panel",
|
||||
): UiNode {
|
||||
return (
|
||||
<PendingRequestBlock
|
||||
|
|
@ -38,6 +39,7 @@ export function pendingRequestBlockNode(
|
|||
autoFocusRequested={autoFocusRequested}
|
||||
consumeAutoFocus={consumeAutoFocus}
|
||||
autoFocusSignature={autoFocusSignature}
|
||||
controlNamespace={controlNamespace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -80,7 +84,13 @@ function PendingRequestBlock({
|
|||
<ApprovalCard key={String(approval.requestId)} approval={approval} approvalDetails={approvalDetails} actions={actions} />
|
||||
))}
|
||||
{pendingUserInputs.map((input) => (
|
||||
<UserInputCard key={String(input.requestId)} input={input} userInputDrafts={userInputDrafts} actions={actions} />
|
||||
<UserInputCard
|
||||
key={String(input.requestId)}
|
||||
input={input}
|
||||
userInputDrafts={userInputDrafts}
|
||||
actions={actions}
|
||||
controlNamespace={controlNamespace}
|
||||
/>
|
||||
))}
|
||||
{pendingMcpElicitations.map((elicitation) => (
|
||||
<McpElicitationCard
|
||||
|
|
@ -88,6 +98,7 @@ function PendingRequestBlock({
|
|||
elicitation={elicitation}
|
||||
mcpElicitationDrafts={mcpElicitationDrafts}
|
||||
actions={actions}
|
||||
controlNamespace={controlNamespace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -98,10 +109,12 @@ function McpElicitationCard({
|
|||
elicitation,
|
||||
mcpElicitationDrafts,
|
||||
actions,
|
||||
controlNamespace,
|
||||
}: {
|
||||
elicitation: PendingMcpElicitationViewModel;
|
||||
mcpElicitationDrafts: ReadonlyMap<string, string>;
|
||||
actions: PendingRequestBlockActions;
|
||||
controlNamespace: string;
|
||||
}): UiNode {
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
const accept = () => {
|
||||
|
|
@ -126,7 +139,12 @@ function McpElicitationCard({
|
|||
accept();
|
||||
}}
|
||||
>
|
||||
<McpElicitationFields fields={elicitation.fields} drafts={mcpElicitationDrafts} actions={actions} />
|
||||
<McpElicitationFields
|
||||
fields={elicitation.fields}
|
||||
drafts={mcpElicitationDrafts}
|
||||
actions={actions}
|
||||
controlNamespace={controlNamespace}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -215,17 +233,19 @@ function UserInputCard({
|
|||
input,
|
||||
userInputDrafts,
|
||||
actions,
|
||||
controlNamespace,
|
||||
}: {
|
||||
input: PendingUserInputViewModel;
|
||||
userInputDrafts: ReadonlyMap<string, string>;
|
||||
actions: PendingRequestBlockActions;
|
||||
controlNamespace: string;
|
||||
}): UiNode {
|
||||
return (
|
||||
<PendingRequestCard className="codex-panel__user-input">
|
||||
<div className="codex-panel__pending-request-info">
|
||||
<div className="codex-panel__pending-request-title">{input.title}</div>
|
||||
<div className="codex-panel__pending-request-body">{input.body}</div>
|
||||
<UserInputQuestions input={input} userInputDrafts={userInputDrafts} actions={actions} />
|
||||
<UserInputQuestions input={input} userInputDrafts={userInputDrafts} actions={actions} controlNamespace={controlNamespace} />
|
||||
</div>
|
||||
<div className="codex-panel__pending-request-actions">
|
||||
<ActionButton
|
||||
|
|
@ -255,10 +275,12 @@ function UserInputQuestions({
|
|||
input,
|
||||
userInputDrafts,
|
||||
actions,
|
||||
controlNamespace,
|
||||
}: {
|
||||
input: PendingUserInputViewModel;
|
||||
userInputDrafts: ReadonlyMap<string, string>;
|
||||
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 (
|
||||
<label key={option.label} className="codex-panel__user-input-option">
|
||||
<input
|
||||
|
|
@ -294,7 +316,7 @@ function UserInputQuestions({
|
|||
})}
|
||||
{question.isOther ? (
|
||||
<OtherUserInputOption
|
||||
groupName={userInputRadioGroupName(input.requestId, question.id)}
|
||||
groupName={userInputRadioGroupName(controlNamespace, input.requestId, question.id)}
|
||||
current={current}
|
||||
optionLabels={new Set(question.options.map((option) => option.label))}
|
||||
question={question}
|
||||
|
|
@ -318,15 +340,17 @@ function McpElicitationFields({
|
|||
fields,
|
||||
drafts,
|
||||
actions,
|
||||
controlNamespace,
|
||||
}: {
|
||||
fields: readonly PendingMcpElicitationFieldViewModel[];
|
||||
drafts: ReadonlyMap<string, string>;
|
||||
actions: PendingRequestBlockActions;
|
||||
controlNamespace: string;
|
||||
}): UiNode {
|
||||
return (
|
||||
<div className="codex-panel__mcp-elicitation-fields">
|
||||
{fields.map((field) => (
|
||||
<McpElicitationField key={field.id} field={field} drafts={drafts} actions={actions} />
|
||||
<McpElicitationField key={field.id} field={field} drafts={drafts} actions={actions} controlNamespace={controlNamespace} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -336,14 +360,16 @@ function McpElicitationField({
|
|||
field,
|
||||
drafts,
|
||||
actions,
|
||||
controlNamespace,
|
||||
}: {
|
||||
field: PendingMcpElicitationFieldViewModel;
|
||||
drafts: ReadonlyMap<string, string>;
|
||||
actions: PendingRequestBlockActions;
|
||||
controlNamespace: string;
|
||||
}): UiNode {
|
||||
const current = drafts.get(field.draftKey) ?? field.defaultDraft;
|
||||
const labelId = mcpElicitationFieldElementId("label", field.draftKey);
|
||||
const controlId = mcpElicitationFieldElementId("control", field.draftKey);
|
||||
const labelId = mcpElicitationFieldElementId(controlNamespace, "label", field.draftKey);
|
||||
const controlId = mcpElicitationFieldElementId(controlNamespace, "control", field.draftKey);
|
||||
const labelContent = (
|
||||
<>
|
||||
<span>{field.title}</span>
|
||||
|
|
@ -362,7 +388,14 @@ function McpElicitationField({
|
|||
</div>
|
||||
)}
|
||||
{field.description ? <div className="codex-panel__mcp-elicitation-description">{field.description}</div> : null}
|
||||
<McpElicitationFieldControl field={field} current={current} actions={actions} controlId={controlId} labelId={labelId} />
|
||||
<McpElicitationFieldControl
|
||||
field={field}
|
||||
current={current}
|
||||
actions={actions}
|
||||
controlId={controlId}
|
||||
labelId={labelId}
|
||||
controlNamespace={controlNamespace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<input
|
||||
className="codex-panel__mcp-elicitation-radio"
|
||||
type="radio"
|
||||
name={`codex-panel-mcp-${field.draftKey}`}
|
||||
name={controlName(controlNamespace, "mcp", field.draftKey)}
|
||||
value={option.value}
|
||||
required={field.required}
|
||||
checked={current === option.value}
|
||||
|
|
@ -480,8 +515,8 @@ function mcpElicitationFieldHasSingleControl(field: PendingMcpElicitationFieldVi
|
|||
return field.type !== "single-select" && field.type !== "multi-select";
|
||||
}
|
||||
|
||||
function mcpElicitationFieldElementId(kind: "control" | "label", draftKey: string): string {
|
||||
return `codex-panel-mcp-${kind}-${encodeURIComponent(draftKey)}`;
|
||||
function mcpElicitationFieldElementId(namespace: string, kind: "control" | "label", draftKey: string): string {
|
||||
return controlName(namespace, "mcp", kind, draftKey);
|
||||
}
|
||||
|
||||
function selectedMcpElicitationValues(draft: string): Set<string> {
|
||||
|
|
@ -494,8 +529,12 @@ function selectedMcpElicitationValues(draft: string): Set<string> {
|
|||
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({
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ function presentationBlockNode(block: ThreadStreamViewBlock, context: ThreadStre
|
|||
false,
|
||||
pendingRequests.consumeAutoFocus,
|
||||
block.signature,
|
||||
pendingRequests.controlNamespace,
|
||||
);
|
||||
}
|
||||
return streamItemNode(block, context);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(".codex-panel__user-input-radio")?.name;
|
||||
const secondName = second.querySelector<HTMLInputElement>(".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<string, string>();
|
||||
|
|
@ -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<HTMLInputElement>(".codex-panel__mcp-elicitation-input"));
|
||||
const label = expectPresent(parent.querySelector<HTMLElement>(".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(),
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ describe("thread stream item renderer decisions", () => {
|
|||
},
|
||||
],
|
||||
pendingRequests: {
|
||||
controlNamespace: "test-panel",
|
||||
signature: "request:1",
|
||||
snapshot: () => ({
|
||||
approvals: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue