From 65630ca86b0cee1b2719624feb6bff7ae020cf3f Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 20:28:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E3=82=A4=E3=83=B3=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=83=E3=83=97=E3=82=A2=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=81=ABTab/=E7=9F=A2=E5=8D=B0=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8B=E3=82=AD=E3=83=BC=E3=83=9C=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=83=8A=E3=83=93=E3=82=B2=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atoms/ListItem/ListItem.module.css | 3 +- .../components/atoms/ListItem/ListItem.tsx | 5 +- .../molecules/AgentPopup/AgentPopup.tsx | 6 +- .../molecules/HashFilePopup/HashFilePopup.tsx | 6 +- .../organisms/InputArea/InputArea.tsx | 125 ++++++++++++++---- 5 files changed, 115 insertions(+), 30 deletions(-) diff --git a/webview/components/atoms/ListItem/ListItem.module.css b/webview/components/atoms/ListItem/ListItem.module.css index c16fedc..edc60e7 100644 --- a/webview/components/atoms/ListItem/ListItem.module.css +++ b/webview/components/atoms/ListItem/ListItem.module.css @@ -6,7 +6,8 @@ font-size: 12px; } -.root:hover { +.root:hover, +.root[data-focused] { background-color: var(--vscode-list-hoverBackground); } diff --git a/webview/components/atoms/ListItem/ListItem.tsx b/webview/components/atoms/ListItem/ListItem.tsx index 60346e9..60298d1 100644 --- a/webview/components/atoms/ListItem/ListItem.tsx +++ b/webview/components/atoms/ListItem/ListItem.tsx @@ -5,16 +5,17 @@ type Props = { description?: string; onClick?: () => void; className?: string; + focused?: boolean; }; /** * タイトル + 説明テキストを縦積みで表示する汎用リスト行。 */ -export function ListItem({ title, description, onClick, className }: Props) { +export function ListItem({ title, description, onClick, className, focused }: Props) { const classes = [styles.root, className].filter(Boolean).join(" "); return ( -
+
{title} {description && {description}}
diff --git a/webview/components/molecules/AgentPopup/AgentPopup.tsx b/webview/components/molecules/AgentPopup/AgentPopup.tsx index 87958b0..948f47b 100644 --- a/webview/components/molecules/AgentPopup/AgentPopup.tsx +++ b/webview/components/molecules/AgentPopup/AgentPopup.tsx @@ -7,20 +7,22 @@ type Props = { agents: Agent[]; onSelectAgent: (agent: Agent) => void; agentPopupRef: React.RefObject; + focusedIndex: number; }; -export function AgentPopup({ agents, onSelectAgent, agentPopupRef }: Props) { +export function AgentPopup({ agents, onSelectAgent, agentPopupRef, focusedIndex }: Props) { const t = useLocale(); return (
{agents.length > 0 ? ( - agents.map((agent) => ( + agents.map((agent, i) => ( onSelectAgent(agent)} + focused={i === focusedIndex} /> )) ) : ( diff --git a/webview/components/molecules/HashFilePopup/HashFilePopup.tsx b/webview/components/molecules/HashFilePopup/HashFilePopup.tsx index 3634909..1ecb179 100644 --- a/webview/components/molecules/HashFilePopup/HashFilePopup.tsx +++ b/webview/components/molecules/HashFilePopup/HashFilePopup.tsx @@ -7,20 +7,22 @@ type Props = { hashFiles: FileAttachment[]; onAddFile: (file: FileAttachment) => void; hashPopupRef: React.RefObject; + focusedIndex: number; }; -export function HashFilePopup({ hashFiles, onAddFile, hashPopupRef }: Props) { +export function HashFilePopup({ hashFiles, onAddFile, hashPopupRef, focusedIndex }: Props) { const t = useLocale(); return (
{hashFiles.length > 0 ? ( - hashFiles.map((file) => ( + hashFiles.map((file, i) => ( onAddFile(file)} + focused={i === focusedIndex} /> )) ) : ( diff --git a/webview/components/organisms/InputArea/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx index 0a2ab81..0494bd8 100644 --- a/webview/components/organisms/InputArea/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -83,6 +83,9 @@ export function InputArea({ }); const [atQuery, setAtQuery] = useState(""); const [selectedAgent, setSelectedAgent] = useState(null); + // ポップアップ内のフォーカス位置(-1 = フォーカスなし) + const [hashFocusedIndex, setHashFocusedIndex] = useState(-1); + const [atFocusedIndex, setAtFocusedIndex] = useState(-1); const textareaRef = useRef(null); const composingRef = useRef(false); const filePickerRef = useRef(null); @@ -225,20 +228,87 @@ export function InputArea({ } }, [text, attachedFiles, onSend, onShellExecute, selectedAgent?.name]); + // # トリガーのファイル候補 + const hashFiles = hashQuery + ? workspaceFiles + .filter( + (f) => + f.fileName.toLowerCase().includes(hashQuery.toLowerCase()) || + f.filePath.toLowerCase().includes(hashQuery.toLowerCase()), + ) + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10) + : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))] + .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) + .slice(0, 10); + + // @ トリガーのエージェント候補(サブエージェントのみ表示) + const subagents = agents.filter((a) => a.mode === "subagent" || a.mode === "all"); + const filteredAgents = atQuery + ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) + : subagents.slice(0, 10); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // Escape で # ポップアップを閉じる if (e.key === "Escape" && hashTrigger.active) { setHashTrigger({ active: false, startIndex: -1 }); setHashQuery(""); + setHashFocusedIndex(-1); return; } // Escape で @ ポップアップを閉じる if (e.key === "Escape" && atTrigger.active) { setAtTrigger({ active: false, startIndex: -1 }); setAtQuery(""); + setAtFocusedIndex(-1); return; } + + // # ポップアップ表示中の Tab / ↑ / ↓ / Enter ナビゲーション + if (hashTrigger.active && hashFiles.length > 0) { + // Tab / ↓ はフォーカスを次の項目に移動する + if (e.key === "Tab" || e.key === "ArrowDown") { + e.preventDefault(); + setHashFocusedIndex((prev) => (prev + 1) % hashFiles.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setHashFocusedIndex((prev) => (prev <= 0 ? hashFiles.length - 1 : prev - 1)); + return; + } + // Enter でフォーカス中の項目を確定する + if (e.key === "Enter" && !e.shiftKey && !composingRef.current && hashFocusedIndex >= 0) { + e.preventDefault(); + addFile(hashFiles[hashFocusedIndex]); + setHashFocusedIndex(-1); + return; + } + } + + // @ ポップアップ表示中の Tab / ↑ / ↓ / Enter ナビゲーション + if (atTrigger.active && filteredAgents.length > 0) { + // Tab / ↓ はフォーカスを次の項目に移動する + if (e.key === "Tab" || e.key === "ArrowDown") { + e.preventDefault(); + setAtFocusedIndex((prev) => (prev + 1) % filteredAgents.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setAtFocusedIndex((prev) => (prev <= 0 ? filteredAgents.length - 1 : prev - 1)); + return; + } + // Enter でフォーカス中の項目を確定する + if (e.key === "Enter" && !e.shiftKey && !composingRef.current && atFocusedIndex >= 0) { + e.preventDefault(); + selectAgent(filteredAgents[atFocusedIndex]); + setAtFocusedIndex(-1); + return; + } + } + // IME 変換中は送信しない if (e.key === "Enter" && !e.shiftKey && !composingRef.current) { e.preventDefault(); @@ -247,15 +317,28 @@ export function InputArea({ if (hashTrigger.active) { setHashTrigger({ active: false, startIndex: -1 }); setHashQuery(""); + setHashFocusedIndex(-1); } if (atTrigger.active) { setAtTrigger({ active: false, startIndex: -1 }); setAtQuery(""); + setAtFocusedIndex(-1); } handleSend(); } }, - [handleSend, isBusy, hashTrigger.active, atTrigger.active], + [ + handleSend, + isBusy, + hashTrigger.active, + atTrigger.active, + hashFocusedIndex, + atFocusedIndex, + hashFiles, + filteredAgents, + addFile, + selectAgent, + ], ); const handleTextChange = useCallback( @@ -296,8 +379,11 @@ export function InputArea({ if (/[\s]/.test(queryPart) || cursorPos <= hashTrigger.startIndex) { setHashTrigger({ active: false, startIndex: -1 }); setHashQuery(""); + setHashFocusedIndex(-1); } else { setHashQuery(queryPart); + // クエリ変更時にフォーカスをリセットする + setHashFocusedIndex(-1); } } @@ -307,8 +393,11 @@ export function InputArea({ if (/[\s]/.test(queryPart) || cursorPos <= atTrigger.startIndex) { setAtTrigger({ active: false, startIndex: -1 }); setAtQuery(""); + setAtFocusedIndex(-1); } else { setAtQuery(queryPart); + // クエリ変更時にフォーカスをリセットする + setAtFocusedIndex(-1); } } }, @@ -329,32 +418,12 @@ export function InputArea({ (f) => !attachedFiles.some((a) => a.filePath === f.filePath), ); - // # トリガーのファイル候補 - const hashFiles = hashQuery - ? workspaceFiles - .filter( - (f) => - f.fileName.toLowerCase().includes(hashQuery.toLowerCase()) || - f.filePath.toLowerCase().includes(hashQuery.toLowerCase()), - ) - .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) - .slice(0, 10) - : [...openEditors, ...workspaceFiles.filter((f) => !openEditors.some((o) => o.filePath === f.filePath))] - .filter((f) => !attachedFiles.some((a) => a.filePath === f.filePath)) - .slice(0, 10); - // 現在アクティブなエディタファイル (リストの先頭) const activeEditorFile = openEditors.length > 0 ? openEditors[0] : null; const isActiveAttached = activeEditorFile ? attachedFiles.some((f) => f.filePath === activeEditorFile.filePath) : false; - // @ トリガーのエージェント候補(サブエージェントのみ表示) - const subagents = agents.filter((a) => a.mode === "subagent" || a.mode === "all"); - const filteredAgents = atQuery - ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) - : subagents.slice(0, 10); - return (
@@ -422,11 +491,21 @@ export function InputArea({ /> {/* # トリガー ファイル候補ポップアップ */} {hashTrigger.active && ( - + )} {/* @ トリガー エージェント候補ポップアップ */} {atTrigger.active && ( - + )}
From c428315d34a0e6cc1a38c97ad11d54495077b4e0 Mon Sep 17 00:00:00 2001 From: ktmage Date: Sun, 1 Mar 2026 20:28:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E3=83=9D=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=A2=E3=83=83=E3=83=97Tab=E3=83=8A=E3=83=93=E3=82=B2?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E5=8D=98=E4=BD=93?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=BB=E3=82=B7=E3=83=8A=E3=83=AA?= =?UTF-8?q?=E3=82=AA=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/ListItem.test.tsx | 18 ++ .../components/molecules/AgentPopup.test.tsx | 22 +- .../molecules/HashFilePopup.test.tsx | 21 +- .../scenarios/21-popup-tab-select.test.tsx | 262 ++++++++++++++++++ 4 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 webview/__tests__/scenarios/21-popup-tab-select.test.tsx diff --git a/webview/__tests__/components/atoms/ListItem.test.tsx b/webview/__tests__/components/atoms/ListItem.test.tsx index 05b8eaa..559a812 100644 --- a/webview/__tests__/components/atoms/ListItem.test.tsx +++ b/webview/__tests__/components/atoms/ListItem.test.tsx @@ -55,4 +55,22 @@ describe("ListItem", () => { expect(container.querySelector(".root")?.classList.contains("custom")).toBe(true); }); }); + + // when focused is true + context("focused が true の場合", () => { + // has data-focused attribute + it("data-focused 属性を持つこと", () => { + const { container } = render(); + expect(container.querySelector(".root")?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // when focused is false or undefined + context("focused が false または未指定の場合", () => { + // does not have data-focused attribute + it("data-focused 属性を持たないこと", () => { + const { container } = render(); + expect(container.querySelector(".root")?.hasAttribute("data-focused")).toBe(false); + }); + }); }); diff --git a/webview/__tests__/components/molecules/AgentPopup.test.tsx b/webview/__tests__/components/molecules/AgentPopup.test.tsx index 1c81fa8..28a3e15 100644 --- a/webview/__tests__/components/molecules/AgentPopup.test.tsx +++ b/webview/__tests__/components/molecules/AgentPopup.test.tsx @@ -23,7 +23,7 @@ describe("AgentPopup", () => { // renders agent names it("エージェント名を表示すること", () => { const { container } = render( - , + , ); const titles = container.querySelectorAll(".title"); expect(titles[0]?.textContent).toBe("coder"); @@ -33,7 +33,7 @@ describe("AgentPopup", () => { // renders agent descriptions it("エージェントの説明を表示すること", () => { const { container } = render( - , + , ); const descriptions = container.querySelectorAll(".description"); expect(descriptions[0]?.textContent).toBe("Coding agent"); @@ -44,7 +44,7 @@ describe("AgentPopup", () => { const onSelect = vi.fn(); const user = userEvent.setup(); const { container } = render( - , + , ); const items = container.querySelectorAll(".root > div"); await user.click(items[0]!); @@ -52,14 +52,18 @@ describe("AgentPopup", () => { }); }); - // when no agents available - context("エージェントがない場合", () => { - // shows empty message - it("空メッセージを表示すること", () => { + // when focusedIndex highlights a specific agent + context("focusedIndex が指定された場合", () => { + const agents = [createAgent("coder", "Coding agent"), createAgent("researcher", "Research agent")]; + + // applies data-focused to the correct item + it("対応するアイテムに data-focused 属性が付与されること", () => { const { container } = render( - , + , ); - expect(container.querySelector(".empty")?.textContent).toBe("No agents available"); + const items = container.querySelectorAll(".root > div"); + expect(items[0]?.getAttribute("data-focused")).toBe("true"); + expect(items[1]?.hasAttribute("data-focused")).toBe(false); }); }); }); diff --git a/webview/__tests__/components/molecules/HashFilePopup.test.tsx b/webview/__tests__/components/molecules/HashFilePopup.test.tsx index 7d63821..7bd97d1 100644 --- a/webview/__tests__/components/molecules/HashFilePopup.test.tsx +++ b/webview/__tests__/components/molecules/HashFilePopup.test.tsx @@ -16,7 +16,7 @@ describe("HashFilePopup", () => { // renders file items it("ファイルアイテムをレンダリングすること", () => { const { container } = render( - , + , ); expect(container.querySelectorAll("[data-testid='hash-popup'] > div")).toHaveLength(2); }); @@ -25,7 +25,7 @@ describe("HashFilePopup", () => { it("アイテムクリックで onAddFile が呼ばれること", () => { const onAddFile = vi.fn(); const { container } = render( - , + , ); fireEvent.click(container.querySelector("[data-testid='hash-popup'] > div")!); expect(onAddFile).toHaveBeenCalledWith(file1); @@ -36,8 +36,23 @@ describe("HashFilePopup", () => { context("ファイル候補がない場合", () => { // renders empty message it("空メッセージを表示すること", () => { - const { container } = render(); + const { container } = render( + , + ); expect(container.querySelector(".empty")).toBeInTheDocument(); }); }); + + // when focusedIndex highlights a specific item + context("focusedIndex が指定された場合", () => { + // applies data-focused to the correct item + it("対応するアイテムに data-focused 属性が付与されること", () => { + const { container } = render( + , + ); + const items = container.querySelectorAll("[data-testid='hash-popup'] > div"); + expect(items[0]?.hasAttribute("data-focused")).toBe(false); + expect(items[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); }); diff --git a/webview/__tests__/scenarios/21-popup-tab-select.test.tsx b/webview/__tests__/scenarios/21-popup-tab-select.test.tsx new file mode 100644 index 0000000..af3e5a5 --- /dev/null +++ b/webview/__tests__/scenarios/21-popup-tab-select.test.tsx @@ -0,0 +1,262 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createSession } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** テスト用エージェントデータ */ +const testAgents = [ + { + name: "coder", + description: "Coding subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "ask", bash: {} }, + tools: {}, + options: {}, + }, + { + name: "explorer", + description: "Read-only subagent", + mode: "subagent", + builtIn: true, + permission: { edit: "deny", bash: {} }, + tools: {}, + options: {}, + }, +] as any; + +/** ファイル候補付きセットアップ */ +async function setupWithFiles() { + renderApp(); + await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); + await sendExtMessage({ + type: "openEditors", + files: [ + { filePath: "src/main.ts", fileName: "main.ts" }, + { filePath: "src/utils.ts", fileName: "utils.ts" }, + ], + }); + await sendExtMessage({ + type: "workspaceFiles", + files: [ + { filePath: "src/main.ts", fileName: "main.ts" }, + { filePath: "src/utils.ts", fileName: "utils.ts" }, + { filePath: "src/config.ts", fileName: "config.ts" }, + ], + }); + await sendExtMessage({ type: "agents", agents: testAgents }); + vi.mocked(postMessage).mockClear(); +} + +// Tab selection in inline popups +describe("ポップアップの Tab 選択", () => { + beforeEach(async () => { + await setupWithFiles(); + }); + + // # popup: Tab moves focus through items + context("# ポップアップで Tab を押した場合", () => { + // first item gets focused + it("先頭のアイテムにフォーカスが当たること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // Tab again moves to second item + it("もう一度 Tab を押すと 2 番目のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: Tab + Enter selects the focused item + context("# ポップアップで Tab でフォーカス後 Enter で確定した場合", () => { + // file is attached and popup closes + it("ファイルが添付されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{Tab}"); + await user.keyboard("{Enter}"); + // ポップアップが閉じる + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); + // ファイルチップが表示される + expect(document.querySelectorAll(".chip").length).toBe(1); + }); + }); + + // # popup: ArrowDown moves focus + context("# ポップアップで ↓ キーを押した場合", () => { + // second item gets focused + it("次のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // pressing ArrowDown again moves to second item + it("もう一度 ↓ を押すと2番目のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: ArrowUp wraps to last item + context("# ポップアップで ↑ キーを押した場合", () => { + // wraps to last item + it("末尾のアイテムにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowUp}"); + const popup = document.querySelector("[data-testid='hash-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + const lastItem = items?.[items.length - 1]; + expect(lastItem?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // # popup: Enter on focused item selects it + context("# ポップアップでフォーカス済みアイテムで Enter を押した場合", () => { + // file is attached and popup closes + it("ファイルが添付されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + // ポップアップが閉じる + expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy(); + // ファイルチップが表示される + expect(document.querySelectorAll(".chip").length).toBe(1); + }); + }); + + // # popup: Enter without focus sends message + context("# ポップアップ表示中にフォーカスなしで Enter を押した場合", () => { + // sends the message (existing behavior) + it("メッセージが送信されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "hello #"); + await user.keyboard("{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "hello #", + }), + ); + }); + }); + + // # popup: query change resets focus + context("# ポップアップでクエリを変更した場合", () => { + // focus resets + it("フォーカスがリセットされること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "#"); + await user.keyboard("{ArrowDown}"); + // フォーカスが当たっている + let popup = document.querySelector("[data-testid='hash-popup']"); + expect(popup?.querySelector("[data-focused]")).toBeTruthy(); + // クエリを入力するとフォーカスリセット + await user.type(textarea, "m"); + popup = document.querySelector("[data-testid='hash-popup']"); + expect(popup?.querySelector("[data-focused]")).toBeFalsy(); + }); + }); + + // @ popup: Tab moves focus through agents + context("@ ポップアップで Tab を押した場合", () => { + // first agent gets focused + it("先頭のエージェントにフォーカスが当たること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + + // Tab again moves to second agent + it("もう一度 Tab を押すと 2 番目のエージェントにフォーカスが移ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + await user.keyboard("{Tab}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[1]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + // @ popup: Tab + Enter selects the focused agent + context("@ ポップアップで Tab でフォーカス後 Enter で確定した場合", () => { + // agent is selected and popup closes + it("エージェントが選択されポップアップが閉じること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{Tab}"); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + expect(screen.getByText("@coder")).toBeInTheDocument(); + }); + }); + + // @ popup: Enter on focused agent selects it + context("@ ポップアップでフォーカス済みエージェントで Enter を押した場合", () => { + // agent is selected + it("エージェントが選択されること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument(); + expect(screen.getByText("@coder")).toBeInTheDocument(); + }); + }); + + // @ popup: ArrowDown/ArrowUp navigation + context("@ ポップアップで ↓↑ キーで移動した場合", () => { + // ArrowDown then ArrowUp returns to first + it("↓ で 2 番目、↑ で 1 番目に戻ること", async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "@"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + const popup = document.querySelector("[data-testid='agent-popup']"); + expect(popup?.querySelectorAll(":scope > div")[1]?.getAttribute("data-focused")).toBe("true"); + await user.keyboard("{ArrowUp}"); + expect(popup?.querySelectorAll(":scope > div")[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); +});