From e6c13640cd82f96ffbcd82675e96d57830e489ae Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 31 May 2026 18:51:03 +0900 Subject: [PATCH] Keep composer suggestions closed after insertion --- src/features/chat/chat-composer-controller.ts | 10 +-- .../chat/chat-composer-controller.test.ts | 83 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/features/chat/chat-composer-controller.test.ts diff --git a/src/features/chat/chat-composer-controller.ts b/src/features/chat/chat-composer-controller.ts index bded5438..32c742cb 100644 --- a/src/features/chat/chat-composer-controller.ts +++ b/src/features/chat/chat-composer-controller.ts @@ -66,7 +66,7 @@ export class ChatComposerController { registerEvent(this.options.app.vault.on("modify", invalidate)); } - render(parent: HTMLElement): void { + render(parent: HTMLElement, options: { updateSuggestions?: boolean } = {}): void { this.parent = parent; const state = this.state; const elements = renderComposerShell( @@ -119,7 +119,7 @@ export class ChatComposerController { ); this.composer = elements.composer; syncComposerHeight(this.composer); - this.updateSuggestions({ renderOnChange: true }); + if (options.updateSuggestions !== false) this.updateSuggestions({ renderOnChange: true }); } setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void { @@ -151,9 +151,9 @@ export class ChatComposerController { this.parent = null; } - refreshControls(parent: HTMLElement | null = this.parent): void { + refreshControls(parent: HTMLElement | null = this.parent, options: { updateSuggestions?: boolean } = {}): void { if (!parent) return; - this.render(parent); + this.render(parent, options); } codexInput(text: string): UserInput[] { @@ -257,7 +257,7 @@ export class ChatComposerController { this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); this.options.onDraftChange(); - this.refreshControls(); + this.refreshControls(this.parent, { updateSuggestions: false }); syncComposerHeight(this.composer); this.composer.focus(); this.composer.setSelectionRange(insertion.cursor, insertion.cursor); diff --git a/tests/features/chat/chat-composer-controller.test.ts b/tests/features/chat/chat-composer-controller.test.ts new file mode 100644 index 00000000..7ac30c29 --- /dev/null +++ b/tests/features/chat/chat-composer-controller.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import type { App } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import { ChatComposerController } from "../../../src/features/chat/chat-composer-controller"; +import { createChatStateStore } from "../../../src/features/chat/chat-state"; +import type { SkillMetadata } from "../../../src/generated/app-server/v2/SkillMetadata"; +import { installObsidianDomShims } from "../../support/dom"; + +installObsidianDomShims(); + +describe("ChatComposerController", () => { + it("keeps suggestions closed after inserting at a cursor before later trigger text", () => { + const stateStore = createChatStateStore(); + stateStore.dispatch({ type: "thread/list-applied", availableSkills: [skill("obsidian-search")] }); + stateStore.dispatch({ type: "composer/draft-set", draft: "/pla then $ob" }); + const parent = document.createElement("div"); + const controller = new ChatComposerController({ + app: app(), + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + canInterrupt: () => false, + composerPlaceholder: () => "Ask Codex to work on this task...", + currentModelForSuggestions: () => null, + renderIfDetached: vi.fn(), + onDraftChange: vi.fn(), + onComposerResize: vi.fn(), + onSubmit: vi.fn(), + onNewThread: vi.fn(), + onThreadScrollFromComposer: vi.fn(), + }); + + controller.render(parent); + composer(parent).setSelectionRange(4, 4); + composer(parent).dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "a" })); + + const planSuggestion = expectPresent(parent.querySelector(".codex-panel__composer-suggestion")); + expect(planSuggestion.textContent).toContain("/plan"); + + planSuggestion.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + + expect(composer(parent).value).toBe("/plan then $ob"); + expect(composer(parent).selectionStart).toBe("/plan".length); + expect(stateStore.getState().composerSuggestions).toEqual([]); + expect(composer(parent).getAttribute("aria-expanded")).toBe("false"); + expect(composer(parent).hasAttribute("aria-activedescendant")).toBe(false); + }); +}); + +function app(): App { + return { + workspace: { + getActiveFile: () => null, + getLastOpenFiles: () => [], + }, + vault: { + getFiles: () => [], + }, + } as unknown as App; +} + +function skill(name: string): SkillMetadata { + return { + name, + description: `${name} description`, + path: `/vault/skills/${name}/SKILL.md`, + scope: "repo", + enabled: true, + }; +} + +function composer(parent: HTMLElement): HTMLTextAreaElement { + return expectPresent(parent.querySelector(".codex-panel__composer-input")); +} + +function expectPresent(value: T | null | undefined): T { + expect(value).not.toBeNull(); + expect(value).not.toBeUndefined(); + return value as T; +}