diff --git a/.vscode/settings.json b/.vscode/settings.json index 6f341d4..c8565c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,5 +42,18 @@ ".ktmage/instructions": true, ".ktmage/project/instructions": true }, - "chat.agent.maxRequests": 100 + "chat.agent.maxRequests": 100, + "accessibility.signalOptions.volume": 70, + "accessibility.signals.chatEditModifiedFile": { + "sound": "on" + }, + "accessibility.signals.chatRequestSent": { + "sound": "on" + }, + "accessibility.signals.chatResponseReceived": { + "sound": "on" + }, + "accessibility.signals.chatUserActionRequired": { + "sound": "on" + } } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c12e4d0..b834a50 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -95,6 +95,28 @@ SOFTWARE. --- +## DOMPurify + +- **Version:** 3.3.1 +- **License:** Apache-2.0 OR MPL-2.0 (distributed under Apache-2.0) +- **Source:** https://github.com/cure53/DOMPurify + +Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + ## Marked - **Version:** 17.0.3 diff --git a/package-lock.json b/package-lock.json index ac45229..114a42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "^1.2.10" + "@opencode-ai/sdk": "^1.2.10", + "@types/dompurify": "^3.0.5", + "dompurify": "^3.3.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.4", + "@biomejs/biome": "2.4.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -2245,6 +2247,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2299,6 +2310,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.109.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", @@ -3533,6 +3550,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", diff --git a/package.json b/package.json index bbc82e9..33ea5e7 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,8 @@ "vitest": "^4.0.18" }, "dependencies": { - "@opencode-ai/sdk": "^1.2.10" + "@opencode-ai/sdk": "^1.2.10", + "@types/dompurify": "^3.0.5", + "dompurify": "^3.3.1" } } diff --git a/vitest.config.ts b/vitest.config.ts index f19162e..0d2b4b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ environment: "jsdom", root: "webview", setupFiles: ["./__tests__/setup.ts"], - include: ["./__tests__/**/*.test.tsx"], + include: ["./__tests__/**/*.test.{ts,tsx}"], globals: true, + css: { + modules: { + classNameStrategy: "non-scoped", + }, + }, }, }); diff --git a/webview/App.tsx b/webview/App.tsx index f7eed0f..9f01436 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,36 +1,34 @@ -import type { Event, Message, Part, Permission, Provider, Session } from "@opencode-ai/sdk"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChatHeader } from "./components/ChatHeader"; -import { EmptyState } from "./components/EmptyState"; -import { InputArea } from "./components/InputArea"; -import { MessagesArea } from "./components/MessagesArea"; -import { SessionList } from "./components/SessionList"; -import { TodoHeader } from "./components/TodoHeader"; -import type { TodoItem } from "./components/ToolPartView"; -import { parseTodos } from "./components/ToolPartView"; -import type { LocaleSetting } from "./locales"; -import { getStrings, LocaleProvider, resolveLocale } from "./locales"; -import type { AllProvidersData, ExtToWebviewMessage, FileAttachment } from "./vscode-api"; -import { getPersistedState, postMessage, setPersistedState } from "./vscode-api"; +import type { Event } from "@opencode-ai/sdk"; +import { useCallback, useEffect, useState } from "react"; +import { EmptyState } from "./components/molecules/EmptyState"; +import { TodoHeader } from "./components/molecules/TodoHeader"; +import { ChatHeader } from "./components/organisms/ChatHeader"; +import { InputArea } from "./components/organisms/InputArea"; +import { MessagesArea } from "./components/organisms/MessagesArea"; +import { SessionList } from "./components/organisms/SessionList"; +import { AppContextProvider, type AppContextValue } from "./contexts/AppContext"; +import { useLocale } from "./hooks/useLocale"; +import { useMessages } from "./hooks/useMessages"; +import { usePermissions } from "./hooks/usePermissions"; +import { useProviders } from "./hooks/useProviders"; +import { useSession } from "./hooks/useSession"; +import { LocaleProvider } from "./locales"; +import type { ExtToWebviewMessage, FileAttachment } from "./vscode-api"; +import { postMessage } from "./vscode-api"; -export type MessageWithParts = { info: Message; parts: Part[] }; +// re-export for consumers that import from App.tsx +export type { MessageWithParts } from "./hooks/useMessages"; export function App() { - const [sessions, setSessions] = useState([]); - const [activeSession, setActiveSession] = useState(null); - const [messages, setMessages] = useState([]); - const [showSessionList, setShowSessionList] = useState(false); - const [sessionBusy, setSessionBusy] = useState(false); - // パーミッションリクエストを messageID でグルーピングして管理する - const [permissions, setPermissions] = useState>(new Map()); - const [providers, setProviders] = useState([]); - const [allProvidersData, setAllProvidersData] = useState(null); - const [selectedModel, setSelectedModel] = useState<{ providerID: string; modelID: string } | null>(null); + const session = useSession(); + const msg = useMessages(); + const prov = useProviders(); + const perm = usePermissions(); + const locale = useLocale(); + + // Extension Host → Webview メッセージでのみ更新される単純なステート const [openEditors, setOpenEditors] = useState([]); const [workspaceFiles, setWorkspaceFiles] = useState([]); - // チェックポイントからの復元時にテキストを入力欄にプリフィルするためのステート - const [prefillText, setPrefillText] = useState(""); - // 設定パネル用のステート const [openCodePaths, setOpenCodePaths] = useState<{ home: string; config: string; @@ -38,151 +36,55 @@ export function App() { directory: string; } | null>(null); - // ロケール管理 - const [localeSetting, setLocaleSetting] = useState( - () => (getPersistedState()?.localeSetting as LocaleSetting) ?? "auto", - ); - const [vscodeLanguage, setVscodeLanguage] = useState("en"); - const resolvedLocale = useMemo(() => resolveLocale(localeSetting, vscodeLanguage), [localeSetting, vscodeLanguage]); - const strings = useMemo(() => getStrings(resolvedLocale), [resolvedLocale]); - - const handleLocaleSettingChange = useCallback((setting: LocaleSetting) => { - setLocaleSetting(setting); - setPersistedState({ ...getPersistedState(), localeSetting: setting }); + const handleOpenConfigFile = useCallback((filePath: string) => { + postMessage({ type: "openConfigFile", filePath }); }, []); - // messages から StepFinishPart のトークン使用量を導出する(圧縮でメッセージが減ると自動的に反映される) - const inputTokens = useMemo(() => { - let total = 0; - for (const m of messages) { - for (const p of m.parts) { - if (p.type === "step-finish" && p.tokens) { - total += p.tokens.input; - } - } - } - return total; - }, [messages]); - - // 選択中のモデルのコンテキストリミットを算出 - const contextLimit = useMemo(() => { - if (!selectedModel) return 0; - const provider = providers.find((p) => p.id === selectedModel.providerID); - if (!provider) return 0; - const model = provider.models[selectedModel.modelID]; - return model?.limit?.context ?? 0; - }, [providers, selectedModel]); - - const handleEvent = useCallback((event: Event) => { - switch (event.type) { - case "message.updated": { - const info = event.properties.info; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === info.id); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { ...updated[idx], info }; - return updated; - } - return [...prev, { info, parts: [] }]; - }); - break; - } - case "message.part.updated": { - const part = event.properties.part; - setMessages((prev) => { - const idx = prev.findIndex((m) => m.info.id === part.messageID); - if (idx < 0) return prev; - const updated = [...prev]; - const msg = { ...updated[idx] }; - const partIdx = msg.parts.findIndex((p) => p.id === part.id); - if (partIdx >= 0) { - msg.parts = [...msg.parts]; - msg.parts[partIdx] = part; - } else { - msg.parts = [...msg.parts, part]; - } - updated[idx] = msg; - return updated; - }); - break; - } - case "session.status": { - setSessionBusy(event.properties.status.type === "busy"); - break; - } - case "permission.updated": { - const permission = event.properties; - setPermissions((prev) => { - const next = new Map(prev); - next.set(permission.id, permission); - return next; - }); - break; - } - case "permission.replied": { - const permissionID = event.properties.permissionID; - setPermissions((prev) => { - const next = new Map(prev); - next.delete(permissionID); - return next; - }); - break; - } - case "message.removed": { - // 圧縮時にメッセージが削除される → messages から除去すると inputTokens も自動的に再計算される - const { messageID } = event.properties; - setMessages((prev) => prev.filter((m) => m.info.id !== messageID)); - break; - } - case "session.updated": { - const info = event.properties.info; - setSessions((prev) => prev.map((s) => (s.id === info.id ? info : s))); - setActiveSession((prev) => (prev?.id === info.id ? info : prev)); - break; - } - case "session.created": { - setSessions((prev) => [event.properties.info, ...prev]); - break; - } - case "session.deleted": { - const deletedId = event.properties.info.id; - setSessions((prev) => prev.filter((s) => s.id !== deletedId)); - break; - } - } + const handleOpenTerminal = useCallback(() => { + postMessage({ type: "openTerminal" }); }, []); + // SSE event handler — dispatches to domain-specific hooks + const handleEvent = useCallback( + (event: Event) => { + session.handleSessionEvent(event); + msg.handleMessageEvent(event); + perm.handlePermissionEvent(event); + }, + [session.handleSessionEvent, msg.handleMessageEvent, perm.handlePermissionEvent], + ); + + // Extension Host → Webview message listener useEffect(() => { const handler = (e: MessageEvent) => { - const msg = e.data; - switch (msg.type) { + const data = e.data; + switch (data.type) { case "sessions": - setSessions(msg.sessions); + session.setSessions(data.sessions); break; case "messages": - if (msg.sessionId === activeSession?.id) { - setMessages(msg.messages); + if (data.sessionId === session.activeSession?.id) { + msg.setMessages(data.messages); } break; case "activeSession": - setActiveSession(msg.session); - if (msg.session) { - postMessage({ type: "getMessages", sessionId: msg.session.id }); + session.setActiveSession(data.session); + if (data.session) { + postMessage({ type: "getMessages", sessionId: data.session.id }); } else { - setMessages([]); + msg.setMessages([]); } break; case "event": - handleEvent(msg.event); + handleEvent(data.event); break; case "providers": { - setProviders(msg.providers); - setAllProvidersData(msg.allProviders); + prov.setProviders(data.providers); + prov.setAllProvidersData(data.allProviders); // サーバーのモデル設定を反映する(config.model → default の順でフォールバック) - setSelectedModel(() => { + prov.setSelectedModel(() => { const modelStr = - msg.configModel || msg.default.general || msg.default.code || Object.values(msg.default)[0]; + data.configModel || data.default.general || data.default.code || Object.values(data.default)[0]; if (!modelStr) return null; const slashIndex = modelStr.indexOf("/"); if (slashIndex < 0) return null; @@ -194,23 +96,23 @@ export function App() { break; } case "openEditors": - setOpenEditors(msg.files); + setOpenEditors(data.files); break; case "workspaceFiles": - setWorkspaceFiles(msg.files); + setWorkspaceFiles(data.files); break; case "toolConfig": - setOpenCodePaths(msg.paths); + setOpenCodePaths(data.paths); break; case "locale": - setVscodeLanguage(msg.vscodeLanguage); + locale.setVscodeLanguage(data.vscodeLanguage); break; case "modelUpdated": { - const slashIndex = msg.model.indexOf("/"); + const slashIndex = data.model.indexOf("/"); if (slashIndex >= 0) { - setSelectedModel({ - providerID: msg.model.slice(0, slashIndex), - modelID: msg.model.slice(slashIndex + 1), + prov.setSelectedModel({ + providerID: data.model.slice(0, slashIndex), + modelID: data.model.slice(slashIndex + 1), }); } break; @@ -221,191 +123,188 @@ export function App() { postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); return () => window.removeEventListener("message", handler); - }, [activeSession?.id, handleEvent]); + }, [ + session.activeSession?.id, + handleEvent, + locale.setVscodeLanguage, + msg.setMessages, + prov.setAllProvidersData, + prov.setProviders, + prov.setSelectedModel, + session.setActiveSession, + session.setSessions, + ]); + + // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( (text: string, files: FileAttachment[]) => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "sendMessage", - sessionId: activeSession.id, + sessionId: session.activeSession.id, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, files: files.length > 0 ? files : undefined, }); }, - [activeSession, selectedModel], + [session.activeSession, prov.selectedModel], ); - const handleNewSession = useCallback(() => { - postMessage({ type: "createSession" }); - setShowSessionList(false); - }, []); - - const handleSelectSession = useCallback((sessionId: string) => { - postMessage({ type: "selectSession", sessionId }); - setShowSessionList(false); - }, []); - - const handleDeleteSession = useCallback((sessionId: string) => { - postMessage({ type: "deleteSession", sessionId }); - }, []); - - const handleModelSelect = useCallback((model: { providerID: string; modelID: string }) => { - setSelectedModel(model); - postMessage({ type: "setModel", model: `${model.providerID}/${model.modelID}` }); - }, []); - const handleAbort = useCallback(() => { - if (!activeSession) return; - postMessage({ type: "abort", sessionId: activeSession.id }); - }, [activeSession]); + if (!session.activeSession) return; + postMessage({ type: "abort", sessionId: session.activeSession.id }); + }, [session.activeSession]); const handleCompress = useCallback(() => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "compressSession", - sessionId: activeSession.id, - model: selectedModel ?? undefined, + sessionId: session.activeSession.id, + model: prov.selectedModel ?? undefined, }); - }, [activeSession, selectedModel]); - - const handleOpenConfigFile = useCallback((filePath: string) => { - postMessage({ type: "openConfigFile", filePath }); - }, []); - - const handleOpenTerminal = useCallback(() => { - postMessage({ type: "openTerminal" }); - }, []); + }, [session.activeSession, prov.selectedModel]); // ユーザーメッセージを編集して再送信する const handleEditAndResend = useCallback( (messageId: string, text: string) => { - if (!activeSession) return; + if (!session.activeSession) return; // messageId は編集対象のユーザーメッセージ。 // その直前のメッセージまで巻き戻し、編集後のテキストを送信する。 - const msgIndex = messages.findIndex((m) => m.info.id === messageId); + const msgIndex = msg.messages.findIndex((m) => m.info.id === messageId); if (msgIndex < 0) return; if (msgIndex === 0) { // 最初のメッセージの場合: 新規セッションを作成して送信する方がクリーン // ただし revert API のフォールバックとして、messageId 自体で revert postMessage({ type: "editAndResend", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, }); } else { // 直前のメッセージまで巻き戻して再送信 - const prevMessageId = messages[msgIndex - 1].info.id; + const prevMessageId = msg.messages[msgIndex - 1].info.id; postMessage({ type: "editAndResend", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId: prevMessageId, text, - model: selectedModel ?? undefined, + model: prov.selectedModel ?? undefined, }); } }, - [activeSession, messages, selectedModel], + [session.activeSession, msg.messages, prov.selectedModel], ); // チェックポイントまで巻き戻す + ユーザーメッセージのテキストを入力欄に復元 const handleRevertToCheckpoint = useCallback( (assistantMessageId: string, userText: string | null) => { - if (!activeSession) return; + if (!session.activeSession) return; postMessage({ type: "revertToMessage", - sessionId: activeSession.id, + sessionId: session.activeSession.id, messageId: assistantMessageId, }); // ユーザーメッセージのテキストを入力欄にプリフィルする - setPrefillText(userText ?? ""); + msg.setPrefillText(userText ?? ""); }, - [activeSession], + [session.activeSession, msg], ); - // メッセージから最新の ToDo リストを導出(todowrite/todoread ツールの最新の出力) - const latestTodos = useMemo(() => { - for (let mi = messages.length - 1; mi >= 0; mi--) { - const parts = messages[mi].parts; - for (let pi = parts.length - 1; pi >= 0; pi--) { - const p = parts[pi]; - if (p.type !== "tool") continue; - if (p.tool !== "todowrite" && p.tool !== "todoread") continue; - const st = p.state; - if (st.status === "completed" && st.output) { - const parsed = parseTodos(st.output); - if (parsed) return parsed; - } - if (st.status !== "pending") { - const input = st.input as Record | undefined; - if (input) { - const parsed = parseTodos(input.todos ?? input); - if (parsed) return parsed; - } - } - } - } - return []; - }, [messages]); + const contextValue: AppContextValue = { + sessions: session.sessions, + activeSession: session.activeSession, + sessionBusy: session.sessionBusy, + showSessionList: session.showSessionList, + onNewSession: session.handleNewSession, + onSelectSession: session.handleSelectSession, + onDeleteSession: session.handleDeleteSession, + onToggleSessionList: session.toggleSessionList, + messages: msg.messages, + inputTokens: msg.inputTokens, + latestTodos: msg.latestTodos, + prefillText: msg.prefillText, + onPrefillConsumed: msg.consumePrefill, + providers: prov.providers, + allProvidersData: prov.allProvidersData, + selectedModel: prov.selectedModel, + onModelSelect: prov.handleModelSelect, + contextLimit: prov.contextLimit, + permissions: perm.permissions, + openEditors, + workspaceFiles, + onSend: handleSend, + onAbort: handleAbort, + onCompress: handleCompress, + isCompressing: !!session.activeSession?.time?.compacting, + onEditAndResend: handleEditAndResend, + onRevertToCheckpoint: handleRevertToCheckpoint, + openCodePaths, + onOpenConfigFile: handleOpenConfigFile, + onOpenTerminal: handleOpenTerminal, + localeSetting: locale.localeSetting, + onLocaleSettingChange: locale.handleLocaleSettingChange, + }; return ( - -
- setShowSessionList((s) => !s)} - /> - {showSessionList && ( - setShowSessionList(false)} + + +
+ - )} - {activeSession ? ( - <> - - {latestTodos.length > 0 && } - setPrefillText("")} - openCodePaths={openCodePaths} - onOpenConfigFile={handleOpenConfigFile} - onOpenTerminal={handleOpenTerminal} - localeSetting={localeSetting} - onLocaleSettingChange={handleLocaleSettingChange} + {session.showSessionList && ( + - - ) : ( - - )} -
+ )} + {session.activeSession ? ( + <> + + {msg.latestTodos.length > 0 && } + + + ) : ( + + )} +
+
); } diff --git a/webview/__tests__/components/atoms/ActionButton.test.tsx b/webview/__tests__/components/atoms/ActionButton.test.tsx new file mode 100644 index 0000000..c3d9c8d --- /dev/null +++ b/webview/__tests__/components/atoms/ActionButton.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ActionButton } from "../../../components/atoms/ActionButton"; + +describe("ActionButton", () => { + // renders a button element + context("デフォルトの描画の場合", () => { + // renders a - {activeSession?.title || t["header.title.fallback"]} -
- -
- - ); -} diff --git a/webview/components/EmptyState.tsx b/webview/components/EmptyState.tsx deleted file mode 100644 index 3c00141..0000000 --- a/webview/components/EmptyState.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useLocale } from "../locales"; - -type Props = { - onNewSession: () => void; -}; - -export function EmptyState({ onNewSession }: Props) { - const t = useLocale(); - return ( -
-
{t["empty.title"]}
-
{t["empty.description"]}
- -
- ); -} diff --git a/webview/components/PermissionView.tsx b/webview/components/PermissionView.tsx deleted file mode 100644 index 4278d52..0000000 --- a/webview/components/PermissionView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Permission } from "@opencode-ai/sdk"; -import { useLocale } from "../locales"; -import { postMessage } from "../vscode-api"; - -type Props = { - permission: Permission; - activeSessionId: string; -}; - -export function PermissionView({ permission, activeSessionId }: Props) { - const t = useLocale(); - const reply = (response: "once" | "always" | "reject") => { - postMessage({ - type: "replyPermission", - sessionId: activeSessionId, - permissionId: permission.id, - response, - }); - }; - - return ( -
-
{permission.title}
-
- - - -
-
- ); -} diff --git a/webview/components/StreamingIndicator.tsx b/webview/components/StreamingIndicator.tsx deleted file mode 100644 index 26c8127..0000000 --- a/webview/components/StreamingIndicator.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export function StreamingIndicator() { - return ( -
- - - -
- ); -} diff --git a/webview/components/TodoHeader.tsx b/webview/components/TodoHeader.tsx deleted file mode 100644 index 535534f..0000000 --- a/webview/components/TodoHeader.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useState } from "react"; -import { useLocale } from "../locales"; -import type { TodoItem } from "./ToolPartView"; - -type Props = { - todos: TodoItem[]; -}; - -export function TodoHeader({ todos }: Props) { - const t = useLocale(); - const [expanded, setExpanded] = useState(false); - - const completed = todos.filter((t) => t.status === "completed" || t.status === "done").length; - const total = todos.length; - - return ( -
-
setExpanded((s) => !s)} title={t["todo.toggleList"]}> - - {t["todo.label"]} - - {completed}/{total} - - - 0 ? (completed / total) * 100 : 0}%` }} - /> - - - - -
- {expanded && ( -
    - {todos.map((todo, i) => { - const isDone = todo.status === "completed" || todo.status === "done"; - const priorityClass = todo.priority === "high" ? "high" : todo.priority === "low" ? "low" : ""; - return ( -
  • - {isDone ? "✓" : "○"} - {todo.content} - {todo.priority && {todo.priority}} -
  • - ); - })} -
- )} -
- ); -} diff --git a/webview/components/ToolConfigPanel.tsx b/webview/components/ToolConfigPanel.tsx deleted file mode 100644 index c6e132d..0000000 --- a/webview/components/ToolConfigPanel.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { LocaleSetting } from "../locales"; -import { useLocale } from "../locales"; - -type Props = { - paths: { home: string; config: string; state: string; directory: string } | null; - onOpenConfigFile: (filePath: string) => void; - onClose: () => void; - localeSetting: LocaleSetting; - onLocaleSettingChange: (setting: LocaleSetting) => void; -}; - -export function ToolConfigPanel({ paths, onOpenConfigFile, onClose, localeSetting, onLocaleSettingChange }: Props) { - const t = useLocale(); - const panelRef = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (panelRef.current && !panelRef.current.contains(e.target as Node)) { - onClose(); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [onClose]); - - return ( -
-
- {t["config.title"]} - -
- -
- {/* Language Setting */} -
-
{t["config.language"]}
-
- {(["auto", "en", "ja"] as const).map((opt) => { - const label = - opt === "auto" ? t["config.langAuto"] : opt === "en" ? t["config.langEn"] : t["config.langJa"]; - return ( - - ); - })} -
-
-
- - {/* 設定ファイルへのリンク */} - {paths && ( -
- - -
- )} -
- ); -} diff --git a/webview/components/ToolPartView.tsx b/webview/components/ToolPartView.tsx deleted file mode 100644 index c4c6844..0000000 --- a/webview/components/ToolPartView.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk"; -import { useMemo, useState } from "react"; -import { useLocale } from "../locales"; - -type Props = { - part: ToolPart; -}; - -// --- ツール種別からアクションラベルとアイコンを決定 --- - -type ToolCategory = "read" | "edit" | "write" | "run" | "search" | "other"; - -const TOOL_CATEGORIES: Record = { - read: "read", - edit: "edit", - multiedit: "edit", - write: "write", - apply_patch: "edit", - bash: "run", - glob: "search", - grep: "search", - list: "search", - codesearch: "search", - websearch: "search", - webfetch: "read", - lsp: "read", - todowrite: "write", - todoread: "read", - task: "run", - batch: "run", - question: "other", - skill: "run", - plan_enter: "other", - plan_exit: "other", -}; - -const CATEGORY_LABEL_KEYS: Record< - ToolCategory, - "tool.read" | "tool.edit" | "tool.create" | "tool.run" | "tool.search" | "tool.tool" -> = { - read: "tool.read", - edit: "tool.edit", - write: "tool.create", - run: "tool.run", - search: "tool.search", - other: "tool.tool", -}; - -function getCategory(toolName: string): ToolCategory { - // 完全一致 - if (TOOL_CATEGORIES[toolName]) return TOOL_CATEGORIES[toolName]; - // MCP ツールなど: プレフィクスを除去して再チェック - const parts = toolName.split(/[_/]/); - const last = parts[parts.length - 1]; - if (TOOL_CATEGORIES[last]) return TOOL_CATEGORIES[last]; - return "other"; -} - -/** タイトル(ファイルパスなど)をフォーマット */ -function formatTitle(part: ToolPart): string | null { - const { state, tool } = part; - const title = state.status === "completed" ? state.title : state.status === "running" ? state.title : null; - if (!title) return null; - // bash の場合はコマンド説明 - if (tool === "bash" || tool === "task") return title; - return title; -} - -/** ファイルパスの末尾ファイル名を取得 */ -function _basename(p: string): string { - return p.split("/").pop() ?? p; -} - -/** input からファイルパスを抽出 */ -function getFilePath(input: Record): string | null { - const fp = input.filePath ?? input.file ?? input.path ?? input.filename; - if (typeof fp === "string") return fp; - return null; -} - -function isFileEditInput(input: Record): boolean { - return typeof input.oldString === "string" && typeof input.newString === "string"; -} - -function isFileCreateInput(input: Record): boolean { - return typeof input.content === "string" && getFilePath(input) !== null && typeof input.oldString !== "string"; -} - -// --- Diff 計算 --- - -type DiffLine = { type: "context" | "add" | "remove"; text: string }; - -function computeLineDiff(oldStr: string, newStr: string): DiffLine[] { - const oldLines = oldStr.split("\n"); - const newLines = newStr.split("\n"); - const result: DiffLine[] = []; - - const n = oldLines.length; - const m = newLines.length; - if (n > 500 || m > 500) { - for (const line of oldLines) result.push({ type: "remove", text: line }); - for (const line of newLines) result.push({ type: "add", text: line }); - return result; - } - - const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); - for (let i = 1; i <= n; i++) { - for (let j = 1; j <= m; j++) { - if (oldLines[i - 1] === newLines[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - const raw: DiffLine[] = []; - let i = n, - j = m; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { - raw.push({ type: "context", text: oldLines[i - 1] }); - i--; - j--; - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - raw.push({ type: "add", text: newLines[j - 1] }); - j--; - } else { - raw.push({ type: "remove", text: oldLines[i - 1] }); - i--; - } - } - raw.reverse(); - - const hasChange = (idx: number) => raw[idx]?.type !== "context"; - for (let k = 0; k < raw.length; k++) { - if (raw[k].type !== "context") { - result.push(raw[k]); - } else { - let nearChange = false; - for (let d = -2; d <= 2; d++) { - if (hasChange(k + d)) { - nearChange = true; - break; - } - } - if (nearChange) result.push(raw[k]); - } - } - - return result; -} - -// --- ToDo --- - -export type TodoItem = { content: string; status?: string; priority?: string }; - -/** JSON文字列からToDoアイテムを抽出 */ -export function parseTodos(raw: unknown): TodoItem[] | null { - try { - const data = typeof raw === "string" ? JSON.parse(raw) : raw; - const arr = Array.isArray(data) ? data : (data?.todos ?? data?.items ?? null); - if (!Array.isArray(arr) || arr.length === 0) return null; - if (!arr.every((item: unknown) => typeof item === "object" && item !== null && "content" in item)) return null; - return arr as TodoItem[]; - } catch { - return null; - } -} - -function TodoView({ todos }: { todos: TodoItem[] }) { - const t = useLocale(); - const completed = todos.filter((td) => td.status === "completed" || td.status === "done").length; - const total = todos.length; - - return ( -
-
{t["tool.completed"](completed, total)}
-
    - {todos.map((todo, i) => { - const isDone = todo.status === "completed" || todo.status === "done"; - const priorityClass = todo.priority === "high" ? "high" : todo.priority === "low" ? "low" : ""; - return ( -
  • - {isDone ? "✓" : "○"} - {todo.content} - {todo.priority && {todo.priority}} -
  • - ); - })} -
-
- ); -} - -// --- サブコンポーネント --- - -function DiffView({ oldStr, newStr }: { oldStr: string; newStr: string }) { - const lines = useMemo(() => computeLineDiff(oldStr, newStr), [oldStr, newStr]); - const addCount = lines.filter((l) => l.type === "add").length; - const removeCount = lines.filter((l) => l.type === "remove").length; - - return ( -
-
- {addCount > 0 && +{addCount}} - {removeCount > 0 && −{removeCount}} -
-
- {lines.map((line, i) => ( -
- - {line.type === "add" ? "+" : line.type === "remove" ? "−" : " "} - - {line.text || "\u00A0"} -
- ))} -
-
- ); -} - -function FileCreateView({ content }: { content: string }) { - const t = useLocale(); - const lines = content.split("\n"); - const displayLines = lines.length > 30 ? [...lines.slice(0, 30), t["tool.moreLines"](lines.length - 30)] : lines; - return ( -
-
- {t["tool.addLines"](lines.length)} -
-
- {displayLines.map((line, i) => ( -
- + - {line || "\u00A0"} -
- ))} -
-
- ); -} - -// --- アクションアイコン --- - -function ActionIcon({ category }: { category: ToolCategory }) { - switch (category) { - case "read": - return ( - - ); - case "edit": - return ( - - ); - case "write": - return ( - - ); - case "run": - return ( - - ); - case "search": - return ( - - ); - default: - return ( - - ); - } -} - -// --- メインコンポーネント --- - -export function ToolPartView({ part }: Props) { - const t = useLocale(); - const [expanded, setExpanded] = useState(false); - const { state } = part; - - const isActive = state.status === "running" || state.status === "pending"; - const isCompleted = state.status === "completed"; - const isError = state.status === "error"; - - const category = getCategory(part.tool); - const actionLabel = t[CATEGORY_LABEL_KEYS[category]]; - const title = formatTitle(part); - - const input = (state.status !== "pending" ? state.input : null) as Record | null; - const isEdit = input ? isFileEditInput(input) : false; - const isCreate = input ? isFileCreateInput(input) : false; - - // ToDo ツール判定 & パース - const isTodoTool = part.tool === "todowrite" || part.tool === "todoread"; - const todos = useMemo(() => { - if (!isTodoTool) return null; - // outputから試す(todoread, todowrite完了後) - if (isCompleted && state.output) { - const fromOutput = parseTodos(state.output); - if (fromOutput) return fromOutput; - } - // inputから試す(todowrite) - if (input) { - const fromInput = parseTodos(input.todos ?? input); - if (fromInput) return fromInput; - } - return null; - }, [isTodoTool, isCompleted, state, input]); - - // タイトル表示: todoツールの場合は件数を正しく表示 - const displayTitle = useMemo(() => { - if (isTodoTool && todos) { - const done = todos.filter((td) => td.status === "completed" || td.status === "done").length; - return t["tool.todos"](done, todos.length); - } - return title; - }, [isTodoTool, todos, title, t["tool.todos"]]); - - return ( -
-
setExpanded((s) => !s)} title={t["tool.toggleDetails"]}> - - {isActive ? ( - - ) : isError ? ( - - ) : ( - - )} - - {actionLabel} - {displayTitle && ( - - {displayTitle} - - )} - - - -
- {expanded && ( -
- {todos ? ( - - ) : isEdit && input ? ( - - ) : isCreate && input ? ( - - ) : ( - <> - {isCompleted && state.output &&
{state.output}
} - {isError &&
{state.error}
} - {isActive && input &&
{JSON.stringify(input, null, 2)}
} - - )} -
- )} -
- ); -} diff --git a/webview/components/atoms/ActionButton/ActionButton.module.css b/webview/components/atoms/ActionButton/ActionButton.module.css new file mode 100644 index 0000000..1da481c --- /dev/null +++ b/webview/components/atoms/ActionButton/ActionButton.module.css @@ -0,0 +1,48 @@ +.root { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + font-size: 12px; + font-family: var(--vscode-font-family); + border: none; + border-radius: 2px; + cursor: pointer; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); +} + +.root:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.root:disabled { + opacity: 0.5; + cursor: default; +} + +.secondary { + color: var(--vscode-button-secondaryForeground); + background-color: var(--vscode-button-secondaryBackground); +} + +.secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +.ghost { + background: none; + border: 1px solid var(--vscode-input-border); + color: var(--vscode-descriptionForeground); +} + +.ghost:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sm { + padding: 3px 10px; + font-size: 11px; + border-radius: 4px; +} diff --git a/webview/components/atoms/ActionButton/ActionButton.tsx b/webview/components/atoms/ActionButton/ActionButton.tsx new file mode 100644 index 0000000..9b146ff --- /dev/null +++ b/webview/components/atoms/ActionButton/ActionButton.tsx @@ -0,0 +1,27 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./ActionButton.module.css"; + +type Props = { + /** 色のバリエーション */ + variant?: "primary" | "secondary" | "ghost"; + /** サイズ */ + size?: "sm" | "md"; + /** ボタン内容(テキスト等) */ + children: ReactNode; +} & Omit, "type">; + +/** + * 背景色ありのアクションボタン。 + * テキスト表示用。Primary(青)/ Secondary(グレー)/ Ghost(枠線のみ)の3バリエーション。 + */ +export function ActionButton({ variant = "primary", size = "md", className, children, ...rest }: Props) { + const classes = [styles.root, variant !== "primary" && styles[variant], size !== "md" && styles[size], className] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/webview/components/atoms/ActionButton/index.ts b/webview/components/atoms/ActionButton/index.ts new file mode 100644 index 0000000..2fd0e00 --- /dev/null +++ b/webview/components/atoms/ActionButton/index.ts @@ -0,0 +1 @@ +export { ActionButton } from "./ActionButton"; diff --git a/webview/components/atoms/ContextIndicator/ContextIndicator.module.css b/webview/components/atoms/ContextIndicator/ContextIndicator.module.css new file mode 100644 index 0000000..61336ec --- /dev/null +++ b/webview/components/atoms/ContextIndicator/ContextIndicator.module.css @@ -0,0 +1,62 @@ +.container { + position: relative; + flex-shrink: 0; +} + +.button { + gap: 4px; + line-height: 1; +} + +.button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.popup { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 6px; + min-width: 220px; + padding: 10px 12px; + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + z-index: 100; +} + +.popupTitle { + font-size: 11px; + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 8px; +} + +.popupRow { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} + +.bar { + height: 4px; + background-color: var(--vscode-input-border); + border-radius: 2px; + margin: 8px 0; + overflow: hidden; +} + +.barFill { + height: 100%; + background-color: var(--vscode-progressBar-background); + border-radius: 2px; + transition: width 0.3s ease; +} + +.compressButton { + width: 100%; + margin-top: 4px; +} diff --git a/webview/components/ContextIndicator.tsx b/webview/components/atoms/ContextIndicator/ContextIndicator.tsx similarity index 67% rename from webview/components/ContextIndicator.tsx rename to webview/components/atoms/ContextIndicator/ContextIndicator.tsx index 5d2d5b9..af5da20 100644 --- a/webview/components/ContextIndicator.tsx +++ b/webview/components/atoms/ContextIndicator/ContextIndicator.tsx @@ -1,5 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocale } from "../locales"; +import { useCallback, useRef, useState } from "react"; +import { useClickOutside } from "../../../hooks/useClickOutside"; +import { useLocale } from "../../../locales"; +import { ActionButton } from "../ActionButton"; +import { IconButton } from "../IconButton"; +import styles from "./ContextIndicator.module.css"; type Props = { inputTokens: number; @@ -8,7 +12,7 @@ type Props = { isCompressing: boolean; }; -function formatTokenCount(n: number): string { +export function formatTokenCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); @@ -19,17 +23,7 @@ export function ContextIndicator({ inputTokens, contextLimit, onCompress, isComp const [showPopup, setShowPopup] = useState(false); const containerRef = useRef(null); - // 外部クリックでポップアップを閉じる - useEffect(() => { - if (!showPopup) return; - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setShowPopup(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [showPopup]); + useClickOutside(containerRef, () => setShowPopup(false), showPopup); const handleClick = useCallback(() => { setShowPopup((s) => !s); @@ -54,10 +48,11 @@ export function ContextIndicator({ inputTokens, contextLimit, onCompress, isComp const color = percent >= 80 ? "var(--vscode-editorWarning-foreground)" : "var(--vscode-textLink-foreground)"; return ( -
- + {showPopup && ( -
-
{t["context.windowUsage"]}
-
+
+
{t["context.windowUsage"]}
+
{t["context.inputTokens"]} {formatTokenCount(inputTokens)}
-
+
{t["context.contextLimit"]} {formatTokenCount(contextLimit)}
-
-
+
+
- +
)}
diff --git a/webview/components/atoms/ContextIndicator/index.ts b/webview/components/atoms/ContextIndicator/index.ts new file mode 100644 index 0000000..ff6ec1f --- /dev/null +++ b/webview/components/atoms/ContextIndicator/index.ts @@ -0,0 +1 @@ +export { ContextIndicator, formatTokenCount } from "./ContextIndicator"; diff --git a/webview/components/atoms/IconButton/IconButton.module.css b/webview/components/atoms/IconButton/IconButton.module.css new file mode 100644 index 0000000..61e598b --- /dev/null +++ b/webview/components/atoms/IconButton/IconButton.module.css @@ -0,0 +1,42 @@ +.root { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + background: none; + border: none; + color: var(--vscode-icon-foreground); + cursor: pointer; + border-radius: 4px; +} + +.root:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.root:disabled { + opacity: 0.5; + cursor: default; +} + +.muted { + color: var(--vscode-descriptionForeground); +} + +.muted:hover { + color: var(--vscode-foreground); +} + +.outlined { + color: var(--vscode-descriptionForeground); + border: 1px solid var(--vscode-input-border); +} + +.outlined:hover { + color: var(--vscode-foreground); + border-color: var(--vscode-foreground); +} + +.sm { + padding: 2px; +} diff --git a/webview/components/atoms/IconButton/IconButton.tsx b/webview/components/atoms/IconButton/IconButton.tsx new file mode 100644 index 0000000..29b4e0a --- /dev/null +++ b/webview/components/atoms/IconButton/IconButton.tsx @@ -0,0 +1,27 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./IconButton.module.css"; + +type Props = { + /** 色のバリエーション */ + variant?: "default" | "muted" | "outlined"; + /** サイズ */ + size?: "sm" | "md"; + /** ボタン内容(アイコン等) */ + children: ReactNode; +} & Omit, "type">; + +/** + * アイコンを表示する透明背景のボタン。 + * デザインはコンポーネントに内包され、レイアウト調整のみ `className` で行う。 + */ +export function IconButton({ variant = "default", size = "md", className, children, ...rest }: Props) { + const classes = [styles.root, variant !== "default" && styles[variant], size !== "md" && styles[size], className] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/webview/components/atoms/IconButton/index.ts b/webview/components/atoms/IconButton/index.ts new file mode 100644 index 0000000..c62ce98 --- /dev/null +++ b/webview/components/atoms/IconButton/index.ts @@ -0,0 +1 @@ +export { IconButton } from "./IconButton"; diff --git a/webview/components/atoms/LinkButton/LinkButton.module.css b/webview/components/atoms/LinkButton/LinkButton.module.css new file mode 100644 index 0000000..870eda5 --- /dev/null +++ b/webview/components/atoms/LinkButton/LinkButton.module.css @@ -0,0 +1,18 @@ +.root { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--vscode-textLink-foreground); + font-size: 11px; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + user-select: none; +} + +.root:hover { + color: var(--vscode-textLink-activeForeground); + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/webview/components/atoms/LinkButton/LinkButton.tsx b/webview/components/atoms/LinkButton/LinkButton.tsx new file mode 100644 index 0000000..98557a3 --- /dev/null +++ b/webview/components/atoms/LinkButton/LinkButton.tsx @@ -0,0 +1,21 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./LinkButton.module.css"; + +type Props = { + /** ボタン内容(icon + テキスト等) */ + children: ReactNode; +} & Omit, "type">; + +/** + * リンクスタイルのボタン。 + * テキストリンク色・背景なし・icon + text の組み合わせで使用する。 + */ +export function LinkButton({ className, children, ...rest }: Props) { + const classes = [styles.root, className].filter(Boolean).join(" "); + + return ( + + ); +} diff --git a/webview/components/atoms/LinkButton/index.ts b/webview/components/atoms/LinkButton/index.ts new file mode 100644 index 0000000..774ce0d --- /dev/null +++ b/webview/components/atoms/LinkButton/index.ts @@ -0,0 +1 @@ +export { LinkButton } from "./LinkButton"; diff --git a/webview/components/atoms/ListItem/ListItem.module.css b/webview/components/atoms/ListItem/ListItem.module.css new file mode 100644 index 0000000..c16fedc --- /dev/null +++ b/webview/components/atoms/ListItem/ListItem.module.css @@ -0,0 +1,23 @@ +.root { + display: flex; + flex-direction: column; + padding: 4px 10px; + cursor: pointer; + font-size: 12px; +} + +.root:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.title { + color: var(--vscode-foreground); +} + +.description { + font-size: 10px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/webview/components/atoms/ListItem/ListItem.tsx b/webview/components/atoms/ListItem/ListItem.tsx new file mode 100644 index 0000000..60346e9 --- /dev/null +++ b/webview/components/atoms/ListItem/ListItem.tsx @@ -0,0 +1,22 @@ +import styles from "./ListItem.module.css"; + +type Props = { + title: string; + description?: string; + onClick?: () => void; + className?: string; +}; + +/** + * タイトル + 説明テキストを縦積みで表示する汎用リスト行。 + */ +export function ListItem({ title, description, onClick, className }: Props) { + const classes = [styles.root, className].filter(Boolean).join(" "); + + return ( +
+ {title} + {description && {description}} +
+ ); +} diff --git a/webview/components/atoms/ListItem/index.ts b/webview/components/atoms/ListItem/index.ts new file mode 100644 index 0000000..fe0e47c --- /dev/null +++ b/webview/components/atoms/ListItem/index.ts @@ -0,0 +1 @@ +export { ListItem } from "./ListItem"; diff --git a/webview/components/atoms/StatusItem/StatusItem.module.css b/webview/components/atoms/StatusItem/StatusItem.module.css new file mode 100644 index 0000000..77c7fd5 --- /dev/null +++ b/webview/components/atoms/StatusItem/StatusItem.module.css @@ -0,0 +1,53 @@ +.root { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 2px 0; + font-size: 12px; + color: var(--vscode-foreground); + line-height: 1.4; +} + +.done { + opacity: 0.6; +} + +.done .content { + text-decoration: line-through; +} + +.indicator { + flex-shrink: 0; + width: 16px; + text-align: center; + font-size: 12px; + line-height: 1.4; +} + +.done .indicator { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.content { + flex: 1; + min-width: 0; +} + +.badge { + flex-shrink: 0; + font-size: 10px; + padding: 0 4px; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.danger { + color: var(--vscode-editorError-foreground, #f44747); + background-color: color-mix(in srgb, var(--vscode-editorError-foreground, #f44747) 12%, transparent); +} + +.muted { + color: var(--vscode-descriptionForeground); + background-color: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent); +} diff --git a/webview/components/atoms/StatusItem/StatusItem.tsx b/webview/components/atoms/StatusItem/StatusItem.tsx new file mode 100644 index 0000000..e38696f --- /dev/null +++ b/webview/components/atoms/StatusItem/StatusItem.tsx @@ -0,0 +1,34 @@ +import styles from "./StatusItem.module.css"; + +export type BadgeVariant = "danger" | "muted"; + +type Props = { + /** 先頭に表示するインジケータ文字(例: "✓", "○") */ + indicator: string; + /** メインコンテンツテキスト */ + content: string; + /** オプショナルバッジ(ラベル + バリアント) */ + badge?: { label: string; variant?: BadgeVariant }; + /** 完了状態(opacity 低下 + 取り消し線) */ + isDone?: boolean; + className?: string; +}; + +/** + * ステータスインジケータ + コンテンツ + オプショナルバッジの読み取り専用行。 + */ +export function StatusItem({ indicator, content, badge, isDone, className }: Props) { + const classes = [styles.root, isDone && styles.done, className].filter(Boolean).join(" "); + + return ( +
  • + {indicator} + {content} + {badge && ( + + {badge.label} + + )} +
  • + ); +} diff --git a/webview/components/atoms/StatusItem/index.ts b/webview/components/atoms/StatusItem/index.ts new file mode 100644 index 0000000..9420820 --- /dev/null +++ b/webview/components/atoms/StatusItem/index.ts @@ -0,0 +1 @@ +export { type BadgeVariant, StatusItem } from "./StatusItem"; diff --git a/webview/components/atoms/StreamingIndicator/StreamingIndicator.module.css b/webview/components/atoms/StreamingIndicator/StreamingIndicator.module.css new file mode 100644 index 0000000..413f9b5 --- /dev/null +++ b/webview/components/atoms/StreamingIndicator/StreamingIndicator.module.css @@ -0,0 +1,35 @@ +.root { + display: inline-flex; + gap: 3px; + align-items: center; + margin-left: 4px; +} + +.dot { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: var(--vscode-progressBar-background); + animation: pulse 1.4s infinite ease-in-out both; +} + +.dot:nth-child(1) { + animation-delay: -0.32s; +} + +.dot:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes pulse { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} diff --git a/webview/components/atoms/StreamingIndicator/StreamingIndicator.tsx b/webview/components/atoms/StreamingIndicator/StreamingIndicator.tsx new file mode 100644 index 0000000..cc5d2ea --- /dev/null +++ b/webview/components/atoms/StreamingIndicator/StreamingIndicator.tsx @@ -0,0 +1,11 @@ +import styles from "./StreamingIndicator.module.css"; + +export function StreamingIndicator() { + return ( +
    + + + +
    + ); +} diff --git a/webview/components/atoms/StreamingIndicator/index.ts b/webview/components/atoms/StreamingIndicator/index.ts new file mode 100644 index 0000000..8410827 --- /dev/null +++ b/webview/components/atoms/StreamingIndicator/index.ts @@ -0,0 +1 @@ +export { StreamingIndicator } from "./StreamingIndicator"; diff --git a/webview/components/atoms/icons/icons.tsx b/webview/components/atoms/icons/icons.tsx new file mode 100644 index 0000000..324c773 --- /dev/null +++ b/webview/components/atoms/icons/icons.tsx @@ -0,0 +1,264 @@ +import type { SVGProps } from "react"; + +// Common props for all icon components. +// Consumers can override width, height, className, and any standard SVG attribute. +export type IconProps = SVGProps; + +// ─── Navigation / Header ───────────────────────────────────────────── + +/** Codicon: list-unordered */ +export function ListIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: add (16px plus) */ +export function AddIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Small plus icon (12px) used for quick-add buttons */ +export function PlusIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +/** Chevron right arrow — used for expand/collapse toggles */ +export function ChevronRightIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +// ─── File / Document ────────────────────────────────────────────────── + +/** Codicon: file */ +export function FileIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: paperclip / attach */ +export function ClipIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Action ─────────────────────────────────────────────────────────── + +/** Codicon: close (×) */ +export function CloseIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: trash / delete */ +export function DeleteIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: edit / pencil */ +export function EditIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: send arrow */ +export function SendIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: stop (square) */ +export function StopIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Revert / retry arrows — used for checkpoint revert */ +export function RevertIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + +// ─── Input Area Actions ─────────────────────────────────────────────── + +/** Codicon: terminal */ +export function TerminalIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: gear / settings */ +export function GearIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +// ─── Tool / Status ──────────────────────────────────────────────────── + +/** Loading spinner — animated rotating arc */ +export function SpinnerIcon({ width = 16, height = 16, className, ...props }: IconProps) { + return ( + + ); +} + +/** Error circle with exclamation mark */ +export function ErrorCircleIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Info circle — used for completed reasoning/thought indicator */ +export function InfoCircleIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Checkbox with checkmark */ +export function CheckboxIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Tool Action Category Icons ─────────────────────────────────────── + +/** Read action icon — open file/document */ +export function ReadActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Edit action icon — pencil (same path as EditIcon) */ +export function EditActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Write/create action icon — file upload */ +export function WriteActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Run action icon — play button in terminal */ +export function RunActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Search action icon — magnifying glass */ +export function SearchActionIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Generic tool/file icon — default for uncategorized tools */ +export function ToolIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +// ─── Visibility ─────────────────────────────────────────────────────── + +/** Eye icon — visible / show */ +export function EyeIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} + +/** Eye-off icon — hidden / hide */ +export function EyeOffIcon({ width = 14, height = 14, ...props }: IconProps) { + return ( + + ); +} diff --git a/webview/components/atoms/icons/index.ts b/webview/components/atoms/icons/index.ts new file mode 100644 index 0000000..6ef8d54 --- /dev/null +++ b/webview/components/atoms/icons/index.ts @@ -0,0 +1 @@ +export * from "./icons"; diff --git a/webview/components/molecules/DiffView/DiffView.module.css b/webview/components/molecules/DiffView/DiffView.module.css new file mode 100644 index 0000000..299cb84 --- /dev/null +++ b/webview/components/molecules/DiffView/DiffView.module.css @@ -0,0 +1,83 @@ +.root { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow: hidden; + font-size: 11px; + font-family: var(--vscode-editor-font-family, monospace); +} + +.stats { + display: flex; + gap: 8px; + padding: 3px 8px; + font-size: 11px; + background-color: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border); +} + +.statAdd { + color: var(--vscode-gitDecoration-addedResourceForeground, #2ea043); +} + +.statRemove { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f85149); +} + +.lines { + max-height: 300px; + overflow-y: auto; + background-color: var(--vscode-editor-background); +} + +.line { + display: flex; + line-height: 1.45; + min-height: 18px; +} + +.lineMarker { + width: 20px; + flex-shrink: 0; + text-align: center; + user-select: none; + color: var(--vscode-descriptionForeground); +} + +.lineText { + flex: 1; + white-space: pre-wrap; + word-break: break-all; + padding-right: 8px; +} + +.lineRemove { + background-color: color-mix(in srgb, var(--vscode-gitDecoration-deletedResourceForeground, #f85149) 15%, transparent); +} + +.lineRemove .lineMarker { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f85149); +} + +.lineRemove .lineText { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f85149); +} + +.lineAdd { + background-color: color-mix(in srgb, var(--vscode-gitDecoration-addedResourceForeground, #2ea043) 15%, transparent); +} + +.lineAdd .lineMarker { + color: var(--vscode-gitDecoration-addedResourceForeground, #2ea043); +} + +.lineAdd .lineText { + color: var(--vscode-gitDecoration-addedResourceForeground, #2ea043); +} + +.lineContext { + background-color: transparent; +} + +.lineContext .lineText { + color: var(--vscode-descriptionForeground); +} diff --git a/webview/components/molecules/DiffView/DiffView.tsx b/webview/components/molecules/DiffView/DiffView.tsx new file mode 100644 index 0000000..34bac79 --- /dev/null +++ b/webview/components/molecules/DiffView/DiffView.tsx @@ -0,0 +1,38 @@ +import { useMemo } from "react"; +import { computeLineDiff } from "../../../utils/diff"; +import styles from "./DiffView.module.css"; + +type LineType = "add" | "remove" | "keep"; +const lineTypeClass: Record = { + add: styles.lineAdd, + remove: styles.lineRemove, + keep: styles.lineContext, +}; + +type Props = { + oldStr: string; + newStr: string; +}; + +export function DiffView({ oldStr, newStr }: Props) { + const lines = useMemo(() => computeLineDiff(oldStr, newStr), [oldStr, newStr]); + const addCount = lines.filter((l) => l.type === "add").length; + const removeCount = lines.filter((l) => l.type === "remove").length; + + return ( +
    +
    + {addCount > 0 && +{addCount}} + {removeCount > 0 && −{removeCount}} +
    +
    + {lines.map((line, i) => ( +
    + {line.type === "add" ? "+" : line.type === "remove" ? "−" : " "} + {line.text || "\u00A0"} +
    + ))} +
    +
    + ); +} diff --git a/webview/components/molecules/DiffView/index.ts b/webview/components/molecules/DiffView/index.ts new file mode 100644 index 0000000..39693ff --- /dev/null +++ b/webview/components/molecules/DiffView/index.ts @@ -0,0 +1 @@ +export { DiffView } from "./DiffView"; diff --git a/webview/components/molecules/EmptyState/EmptyState.module.css b/webview/components/molecules/EmptyState/EmptyState.module.css new file mode 100644 index 0000000..f6dbc66 --- /dev/null +++ b/webview/components/molecules/EmptyState/EmptyState.module.css @@ -0,0 +1,22 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + color: var(--vscode-descriptionForeground); + text-align: center; +} + +.title { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--vscode-foreground); +} + +.description { + font-size: 12px; + margin-bottom: 16px; +} diff --git a/webview/components/molecules/EmptyState/EmptyState.tsx b/webview/components/molecules/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..d757f6a --- /dev/null +++ b/webview/components/molecules/EmptyState/EmptyState.tsx @@ -0,0 +1,18 @@ +import { useLocale } from "../../../locales"; +import { ActionButton } from "../../atoms/ActionButton"; +import styles from "./EmptyState.module.css"; + +type Props = { + onNewSession: () => void; +}; + +export function EmptyState({ onNewSession }: Props) { + const t = useLocale(); + return ( +
    +
    {t["empty.title"]}
    +
    {t["empty.description"]}
    + {t["empty.newChat"]} +
    + ); +} diff --git a/webview/components/molecules/EmptyState/index.ts b/webview/components/molecules/EmptyState/index.ts new file mode 100644 index 0000000..aac2d4d --- /dev/null +++ b/webview/components/molecules/EmptyState/index.ts @@ -0,0 +1 @@ +export { EmptyState } from "./EmptyState"; diff --git a/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css b/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css new file mode 100644 index 0000000..968663a --- /dev/null +++ b/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css @@ -0,0 +1,117 @@ +.left { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.clipContainer { + position: relative; +} + +.fileButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + background: none; + border: 1px dashed var(--vscode-input-border); + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-font-family); + font-size: 11px; + cursor: pointer; + border-radius: 4px; +} + +.fileButton:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); + border-color: var(--vscode-foreground); +} + +.pickerDropdown { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 280px; + max-width: 400px; + max-height: 300px; + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; + display: flex; + flex-direction: column; +} + +.pickerSearch { + width: 100%; + padding: 6px 10px; + background-color: var(--vscode-input-background); + border: none; + border-bottom: 1px solid var(--vscode-panel-border); + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + font-size: 12px; + outline: none; +} + +.pickerSearch::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.pickerList { + overflow-y: auto; + max-height: 250px; +} + +.pickerEmpty { + padding: 8px 10px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-bottom: 4px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + background-color: var(--vscode-input-background); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + font-size: 11px; +} + +.chipName { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chipRemove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + color: var(--vscode-badge-foreground); + cursor: pointer; + border-radius: 2px; + opacity: 0.7; +} + +.chipRemove:hover { + opacity: 1; +} diff --git a/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx b/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx new file mode 100644 index 0000000..f452614 --- /dev/null +++ b/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx @@ -0,0 +1,99 @@ +import { useLocale } from "../../../locales"; +import type { FileAttachment } from "../../../vscode-api"; +import { IconButton } from "../../atoms/IconButton"; +import { ClipIcon, CloseIcon, PlusIcon } from "../../atoms/icons"; +import { ListItem } from "../../atoms/ListItem"; +import styles from "./FileAttachmentBar.module.css"; + +type Props = { + attachedFiles: FileAttachment[]; + activeEditorFile: FileAttachment | null; + isActiveAttached: boolean; + showFilePicker: boolean; + filePickerQuery: string; + pickerFiles: FileAttachment[]; + onClipClick: () => void; + onFilePickerSearch: (query: string) => void; + onAddFile: (file: FileAttachment) => void; + onRemoveFile: (filePath: string) => void; + filePickerRef: React.RefObject; +}; + +export function FileAttachmentBar({ + attachedFiles, + activeEditorFile, + isActiveAttached, + showFilePicker, + filePickerQuery, + pickerFiles, + onClipClick, + onFilePickerSearch, + onAddFile, + onRemoveFile, + filePickerRef, +}: Props) { + const t = useLocale(); + + return ( +
    + {/* クリップボタン */} +
    + + + + {showFilePicker && ( +
    + onFilePickerSearch(e.target.value)} + /> +
    + {pickerFiles.length > 0 ? ( + pickerFiles + .slice(0, 15) + .map((file) => ( + onAddFile(file)} + /> + )) + ) : ( +
    {t["input.noFiles"]}
    + )} +
    +
    + )} +
    + {/* 添付されたファイルチップ (インライン) */} + {attachedFiles.map((file) => ( +
    + {file.fileName} + +
    + ))} + {/* 現在開いているファイルの quick-add ボタン */} + {activeEditorFile && !isActiveAttached && ( + + )} +
    + ); +} diff --git a/webview/components/molecules/FileAttachmentBar/index.ts b/webview/components/molecules/FileAttachmentBar/index.ts new file mode 100644 index 0000000..604053a --- /dev/null +++ b/webview/components/molecules/FileAttachmentBar/index.ts @@ -0,0 +1 @@ +export { FileAttachmentBar } from "./FileAttachmentBar"; diff --git a/webview/components/molecules/FileCreateView/FileCreateView.tsx b/webview/components/molecules/FileCreateView/FileCreateView.tsx new file mode 100644 index 0000000..36a1de7 --- /dev/null +++ b/webview/components/molecules/FileCreateView/FileCreateView.tsx @@ -0,0 +1,27 @@ +import { useLocale } from "../../../locales"; +import styles from "../DiffView/DiffView.module.css"; + +type Props = { + content: string; +}; + +export function FileCreateView({ content }: Props) { + const t = useLocale(); + const lines = content.split("\n"); + const displayLines = lines.length > 30 ? [...lines.slice(0, 30), t["tool.moreLines"](lines.length - 30)] : lines; + return ( +
    +
    + {t["tool.addLines"](lines.length)} +
    +
    + {displayLines.map((line, i) => ( +
    + + + {line || "\u00A0"} +
    + ))} +
    +
    + ); +} diff --git a/webview/components/molecules/FileCreateView/index.ts b/webview/components/molecules/FileCreateView/index.ts new file mode 100644 index 0000000..e5726b6 --- /dev/null +++ b/webview/components/molecules/FileCreateView/index.ts @@ -0,0 +1 @@ +export { FileCreateView } from "./FileCreateView"; diff --git a/webview/components/molecules/HashFilePopup/HashFilePopup.module.css b/webview/components/molecules/HashFilePopup/HashFilePopup.module.css new file mode 100644 index 0000000..f8ba5c0 --- /dev/null +++ b/webview/components/molecules/HashFilePopup/HashFilePopup.module.css @@ -0,0 +1,21 @@ +.root { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 240px; + max-width: 360px; + max-height: 200px; + overflow-y: auto; + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.empty { + padding: 8px 10px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} diff --git a/webview/components/molecules/HashFilePopup/HashFilePopup.tsx b/webview/components/molecules/HashFilePopup/HashFilePopup.tsx new file mode 100644 index 0000000..3634909 --- /dev/null +++ b/webview/components/molecules/HashFilePopup/HashFilePopup.tsx @@ -0,0 +1,31 @@ +import { useLocale } from "../../../locales"; +import type { FileAttachment } from "../../../vscode-api"; +import { ListItem } from "../../atoms/ListItem"; +import styles from "./HashFilePopup.module.css"; + +type Props = { + hashFiles: FileAttachment[]; + onAddFile: (file: FileAttachment) => void; + hashPopupRef: React.RefObject; +}; + +export function HashFilePopup({ hashFiles, onAddFile, hashPopupRef }: Props) { + const t = useLocale(); + + return ( +
    + {hashFiles.length > 0 ? ( + hashFiles.map((file) => ( + onAddFile(file)} + /> + )) + ) : ( +
    {t["input.noFiles"]}
    + )} +
    + ); +} diff --git a/webview/components/molecules/HashFilePopup/index.ts b/webview/components/molecules/HashFilePopup/index.ts new file mode 100644 index 0000000..4d7d8fa --- /dev/null +++ b/webview/components/molecules/HashFilePopup/index.ts @@ -0,0 +1 @@ +export { HashFilePopup } from "./HashFilePopup"; diff --git a/webview/components/molecules/ModelSelector/ModelSelector.module.css b/webview/components/molecules/ModelSelector/ModelSelector.module.css new file mode 100644 index 0000000..e91753c --- /dev/null +++ b/webview/components/molecules/ModelSelector/ModelSelector.module.css @@ -0,0 +1,200 @@ +.root { + position: relative; +} + +.button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: none; + border: none; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-font-family); + font-size: 11px; + cursor: pointer; + border-radius: 3px; +} + +.button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.label { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.chevron.expanded { + transform: rotate(90deg); +} + +.panel { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + min-width: 280px; + max-height: 400px; + display: flex; + flex-direction: column; + background-color: var(--vscode-editorWidget-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border)); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 100; + overflow: hidden; +} + +.panelBody { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 4px 0; +} + +.panelBody::-webkit-scrollbar { + width: 6px; +} + +.panelBody::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + border-radius: 3px; +} + +.section { + /* wrapper for provider group */ +} + +.sectionTitle { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px 4px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + cursor: pointer; + user-select: none; +} + +.sectionTitle:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.sectionTitle.disconnected { + opacity: 0.6; +} + +.sectionName { + flex: 1; +} + +.sectionBadge { + font-size: 10px; + font-weight: 400; + text-transform: none; + color: var(--vscode-editorWarning-foreground, #ff9800); + flex-shrink: 0; +} + +.item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + color: var(--vscode-foreground); +} + +.item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.item.active { + color: var(--vscode-textLink-foreground); +} + +.item.disabled { + opacity: 0.45; + cursor: default; +} + +.item.disabled:hover { + background-color: transparent; +} + +.itemCheck { + width: 14px; + font-size: 12px; + text-align: center; + flex-shrink: 0; +} + +.itemName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; +} + +.itemBadge { + font-size: 9px; + padding: 0 4px; + border-radius: 3px; + text-transform: uppercase; + font-weight: 600; + flex-shrink: 0; +} + +.itemBadge.beta { + color: var(--vscode-editorInfo-foreground, #3794ff); + background-color: color-mix(in srgb, var(--vscode-editorInfo-foreground, #3794ff) 15%, transparent); +} + +.itemBadge.alpha { + color: var(--vscode-editorWarning-foreground, #ff9800); + background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #ff9800) 15%, transparent); +} + +.itemBadge.deprecated { + color: var(--vscode-errorForeground); + background-color: color-mix(in srgb, var(--vscode-errorForeground) 15%, transparent); +} + +.itemMeta { + display: flex; + gap: 6px; + font-size: 10px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; +} + +.itemContext { + font-family: var(--vscode-editor-font-family, monospace); +} + +.footer { + display: flex; + gap: 4px; + padding: 6px 12px; + border-top: 1px solid var(--vscode-panel-border); + flex-shrink: 0; +} diff --git a/webview/components/ModelSelector.tsx b/webview/components/molecules/ModelSelector/ModelSelector.tsx similarity index 57% rename from webview/components/ModelSelector.tsx rename to webview/components/molecules/ModelSelector/ModelSelector.tsx index 0893159..4d8dad5 100644 --- a/webview/components/ModelSelector.tsx +++ b/webview/components/molecules/ModelSelector/ModelSelector.tsx @@ -1,7 +1,11 @@ import type { Provider } from "@opencode-ai/sdk"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocale } from "../locales"; -import type { AllProvidersData, ModelInfo, ProviderInfo } from "../vscode-api"; +import { useMemo, useRef, useState } from "react"; +import { useClickOutside } from "../../../hooks/useClickOutside"; +import { useLocale } from "../../../locales"; +import type { AllProvidersData, ModelInfo, ProviderInfo } from "../../../vscode-api"; +import { ChevronRightIcon, EyeIcon, EyeOffIcon } from "../../atoms/icons"; +import { LinkButton } from "../../atoms/LinkButton"; +import styles from "./ModelSelector.module.css"; type Props = { providers: Provider[]; @@ -20,6 +24,12 @@ function statusBadge(status?: string): string | null { return status; } +const badgeClass: Record = { + beta: styles.beta, + alpha: styles.alpha, + deprecated: styles.deprecated, +}; + export function ModelSelector({ providers, allProvidersData, selectedModel, onSelect }: Props) { const t = useLocale(); const [open, setOpen] = useState(false); @@ -27,16 +37,7 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe const [showAll, setShowAll] = useState(false); const containerRef = useRef(null); - useEffect(() => { - if (!open) return; - const handleClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [open]); + useClickOutside(containerRef, () => setOpen(false), open); // 表示用プロバイダーリスト: allProvidersData があればそちらを使い、なければ従来の providers を使う const allDisplayProviders = useMemo(() => { @@ -97,41 +98,30 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe }; return ( -
    - {open && ( -
    -
    +
    +
    {displayProviders.map((provider) => { if (provider.models.length === 0) return null; const isCollapsed = collapsedProviders.has(provider.id); return ( -
    +
    toggleProvider(provider.id)} > - - + + - {provider.name} - {!provider.connected && ( - {t["model.notConnected"]} - )} + {provider.name} + {!provider.connected && {t["model.notConnected"]}}
    {!isCollapsed && provider.models.map((model) => { @@ -142,21 +132,23 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe return (
    { if (disabled) return; onSelect({ providerID: provider.id, modelID: model.id }); setOpen(false); }} > - {isSelected ? "✓" : ""} - + {isSelected ? "✓" : ""} + {model.name || model.id} - {badge && {badge}} + {badge && {badge}} {model.limit && ( - - {formatContextK(model.limit.context)} + + {formatContextK(model.limit.context)} )}
    @@ -167,22 +159,14 @@ export function ModelSelector({ providers, allProvidersData, selectedModel, onSe })}
    {hasDisconnected && ( -
    - +
    )}
    diff --git a/webview/components/molecules/ModelSelector/index.ts b/webview/components/molecules/ModelSelector/index.ts new file mode 100644 index 0000000..fad7c20 --- /dev/null +++ b/webview/components/molecules/ModelSelector/index.ts @@ -0,0 +1 @@ +export { ModelSelector } from "./ModelSelector"; diff --git a/webview/components/TextPartView.tsx b/webview/components/molecules/TextPartView/TextPartView.tsx similarity index 51% rename from webview/components/TextPartView.tsx rename to webview/components/molecules/TextPartView/TextPartView.tsx index a114831..853124a 100644 --- a/webview/components/TextPartView.tsx +++ b/webview/components/molecules/TextPartView/TextPartView.tsx @@ -1,8 +1,9 @@ import type { TextPart } from "@opencode-ai/sdk"; +import DOMPurify from "dompurify"; import { marked } from "marked"; import { useMemo } from "react"; -// marked のデフォルト設定: XSS 対策のため HTML タグをエスケープする +// marked のデフォルト設定 marked.setOptions({ breaks: true, }); @@ -13,9 +14,10 @@ type Props = { export function TextPartView({ part }: Props) { const html = useMemo(() => { - return marked.parse(part.text, { async: false }) as string; + const raw = marked.parse(part.text, { async: false }) as string; + return DOMPurify.sanitize(raw); }, [part.text]); - // biome-ignore lint/security/noDangerouslySetInnerHtml: marked v17 はデフォルトで HTML タグをエスケープするため、sanitize 済み HTML の描画に使用する + // biome-ignore lint/security/noDangerouslySetInnerHtml: DOMPurify でサニタイズ済みの HTML を描画する return ; } diff --git a/webview/components/molecules/TextPartView/index.ts b/webview/components/molecules/TextPartView/index.ts new file mode 100644 index 0000000..b669f76 --- /dev/null +++ b/webview/components/molecules/TextPartView/index.ts @@ -0,0 +1 @@ +export { TextPartView } from "./TextPartView"; diff --git a/webview/components/molecules/TodoHeader/TodoHeader.module.css b/webview/components/molecules/TodoHeader/TodoHeader.module.css new file mode 100644 index 0000000..115a6cb --- /dev/null +++ b/webview/components/molecules/TodoHeader/TodoHeader.module.css @@ -0,0 +1,70 @@ +.root { + border-top: 1px solid var(--vscode-panel-border); + background-color: var(--vscode-sideBar-background); +} + +.bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + cursor: pointer; + user-select: none; + font-size: 12px; +} + +.bar:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.icon { + flex-shrink: 0; + color: var(--vscode-textLink-foreground); +} + +.label { + font-weight: 600; + font-size: 11px; + color: var(--vscode-foreground); +} + +.count { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); +} + +.progress { + flex: 1; + height: 3px; + background-color: var(--vscode-input-border); + border-radius: 2px; + overflow: hidden; + max-width: 80px; +} + +.progressFill { + display: block; + height: 100%; + background-color: var(--vscode-testing-iconPassed, #73c991); + border-radius: 2px; + transition: width 0.3s ease; +} + +.chevron { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + transition: transform 0.15s; +} + +.chevron.expanded { + transform: rotate(90deg); +} + +.list { + list-style: none; + margin: 0; + padding: 0 12px 8px 12px; +} diff --git a/webview/components/molecules/TodoHeader/TodoHeader.tsx b/webview/components/molecules/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000..1ae6ea5 --- /dev/null +++ b/webview/components/molecules/TodoHeader/TodoHeader.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { useLocale } from "../../../locales"; +import type { TodoItem } from "../../../utils/todo"; +import { CheckboxIcon, ChevronRightIcon } from "../../atoms/icons"; +import type { BadgeVariant } from "../../atoms/StatusItem"; +import { StatusItem } from "../../atoms/StatusItem"; +import styles from "./TodoHeader.module.css"; + +type Props = { + todos: TodoItem[]; +}; + +export function TodoHeader({ todos }: Props) { + const t = useLocale(); + const [expanded, setExpanded] = useState(false); + + const completed = todos.filter((t) => t.status === "completed" || t.status === "done").length; + const total = todos.length; + + return ( +
    +
    setExpanded((s) => !s)} title={t["todo.toggleList"]}> + + {t["todo.label"]} + + {completed}/{total} + + + 0 ? (completed / total) * 100 : 0}%` }} /> + + + + +
    + {expanded && ( +
      + {todos.map((todo, i) => { + const isDone = todo.status === "completed" || todo.status === "done"; + const badge = todo.priority + ? { label: todo.priority, variant: (todo.priority === "high" ? "danger" : "muted") as BadgeVariant } + : undefined; + return ( + + ); + })} +
    + )} +
    + ); +} diff --git a/webview/components/molecules/TodoHeader/index.ts b/webview/components/molecules/TodoHeader/index.ts new file mode 100644 index 0000000..effa6b8 --- /dev/null +++ b/webview/components/molecules/TodoHeader/index.ts @@ -0,0 +1 @@ +export { TodoHeader } from "./TodoHeader"; diff --git a/webview/components/molecules/TodoView/TodoView.module.css b/webview/components/molecules/TodoView/TodoView.module.css new file mode 100644 index 0000000..7acc769 --- /dev/null +++ b/webview/components/molecules/TodoView/TodoView.module.css @@ -0,0 +1,18 @@ +.root { + padding: 8px 12px; +} + +.summary { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} diff --git a/webview/components/molecules/TodoView/TodoView.tsx b/webview/components/molecules/TodoView/TodoView.tsx new file mode 100644 index 0000000..1b33f1a --- /dev/null +++ b/webview/components/molecules/TodoView/TodoView.tsx @@ -0,0 +1,32 @@ +import { useLocale } from "../../../locales"; +import type { TodoItem } from "../../../utils/todo"; +import type { BadgeVariant } from "../../atoms/StatusItem"; +import { StatusItem } from "../../atoms/StatusItem"; +import styles from "./TodoView.module.css"; + +type Props = { + todos: TodoItem[]; +}; + +export function TodoView({ todos }: Props) { + const t = useLocale(); + const completed = todos.filter((td) => td.status === "completed" || td.status === "done").length; + const total = todos.length; + + return ( +
    +
    {t["tool.completed"](completed, total)}
    +
      + {todos.map((todo, i) => { + const isDone = todo.status === "completed" || todo.status === "done"; + const badge = todo.priority + ? { label: todo.priority, variant: (todo.priority === "high" ? "danger" : "muted") as BadgeVariant } + : undefined; + return ( + + ); + })} +
    +
    + ); +} diff --git a/webview/components/molecules/TodoView/index.ts b/webview/components/molecules/TodoView/index.ts new file mode 100644 index 0000000..f28ba14 --- /dev/null +++ b/webview/components/molecules/TodoView/index.ts @@ -0,0 +1 @@ +export { TodoView } from "./TodoView"; diff --git a/webview/components/organisms/ChatHeader/ChatHeader.module.css b/webview/components/organisms/ChatHeader/ChatHeader.module.css new file mode 100644 index 0000000..69cfa5e --- /dev/null +++ b/webview/components/organisms/ChatHeader/ChatHeader.module.css @@ -0,0 +1,23 @@ +.root { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-bottom: 1px solid var(--vscode-panel-border); + min-height: 28px; +} + +.title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions { + display: flex; + gap: 4px; +} diff --git a/webview/components/organisms/ChatHeader/ChatHeader.tsx b/webview/components/organisms/ChatHeader/ChatHeader.tsx new file mode 100644 index 0000000..bc0d30f --- /dev/null +++ b/webview/components/organisms/ChatHeader/ChatHeader.tsx @@ -0,0 +1,28 @@ +import type { Session } from "@opencode-ai/sdk"; +import { useLocale } from "../../../locales"; +import { IconButton } from "../../atoms/IconButton"; +import { AddIcon, ListIcon } from "../../atoms/icons"; +import styles from "./ChatHeader.module.css"; + +type Props = { + activeSession: Session | null; + onNewSession: () => void; + onToggleSessionList: () => void; +}; + +export function ChatHeader({ activeSession, onNewSession, onToggleSessionList }: Props) { + const t = useLocale(); + return ( +
    + + + + {activeSession?.title || t["header.title.fallback"]} +
    + + + +
    +
    + ); +} diff --git a/webview/components/organisms/ChatHeader/index.ts b/webview/components/organisms/ChatHeader/index.ts new file mode 100644 index 0000000..04b0f33 --- /dev/null +++ b/webview/components/organisms/ChatHeader/index.ts @@ -0,0 +1 @@ +export { ChatHeader } from "./ChatHeader"; diff --git a/webview/components/organisms/InputArea/InputArea.module.css b/webview/components/organisms/InputArea/InputArea.module.css new file mode 100644 index 0000000..98bf839 --- /dev/null +++ b/webview/components/organisms/InputArea/InputArea.module.css @@ -0,0 +1,80 @@ +.root { + border-top: 1px solid var(--vscode-panel-border); + padding: 8px 12px; +} + +.wrapper { + display: flex; + flex-direction: column; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + padding: 4px 8px; +} + +.wrapper:focus-within { + border-color: var(--vscode-focusBorder); +} + +.contextBar { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 4px; +} + +.textareaContainer { + position: relative; +} + +.textarea { + flex: 1; + width: 100%; + background: none; + border: none; + outline: none; + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family); + font-size: 13px; + line-height: 1.4; + resize: none; + max-height: 120px; + min-height: 20px; +} + +.textarea::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-top: 4px; + position: relative; +} + +.actionsLeft { + display: flex; + align-items: center; + gap: 4px; + position: relative; +} + +.sendButton { + flex-shrink: 0; +} + +.chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.expanded { + transform: rotate(90deg); +} diff --git a/webview/components/InputArea.tsx b/webview/components/organisms/InputArea/InputArea.tsx similarity index 54% rename from webview/components/InputArea.tsx rename to webview/components/organisms/InputArea/InputArea.tsx index 2c6c952..14ae7b7 100644 --- a/webview/components/InputArea.tsx +++ b/webview/components/organisms/InputArea/InputArea.tsx @@ -1,12 +1,18 @@ import type { Provider } from "@opencode-ai/sdk"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import type { LocaleSetting } from "../locales"; -import { useLocale } from "../locales"; -import type { AllProvidersData, FileAttachment } from "../vscode-api"; -import { postMessage } from "../vscode-api"; -import { ContextIndicator } from "./ContextIndicator"; -import { ModelSelector } from "./ModelSelector"; -import { ToolConfigPanel } from "./ToolConfigPanel"; +import { useClickOutside } from "../../../hooks/useClickOutside"; +import type { LocaleSetting } from "../../../locales"; +import { useLocale } from "../../../locales"; +import type { AllProvidersData, FileAttachment } from "../../../vscode-api"; +import { postMessage } from "../../../vscode-api"; +import { ContextIndicator } from "../../atoms/ContextIndicator"; +import { IconButton } from "../../atoms/IconButton"; +import { ChevronRightIcon, GearIcon, SendIcon, StopIcon, TerminalIcon } from "../../atoms/icons"; +import { FileAttachmentBar } from "../../molecules/FileAttachmentBar"; +import { HashFilePopup } from "../../molecules/HashFilePopup"; +import { ModelSelector } from "../../molecules/ModelSelector"; +import { ToolConfigPanel } from "../../organisms/ToolConfigPanel"; +import styles from "./InputArea.module.css"; type Props = { onSend: (text: string, files: FileAttachment[]) => void; @@ -130,34 +136,17 @@ export function InputArea({ }, []); // 外部クリックでファイルピッカーを閉じる - useEffect(() => { - if (!showFilePicker) return; - const handler = (e: MouseEvent) => { - if (filePickerRef.current && !filePickerRef.current.contains(e.target as Node)) { - setShowFilePicker(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [showFilePicker]); + useClickOutside(filePickerRef, () => setShowFilePicker(false), showFilePicker); - // 外部クリックで # ポップアップを閉じる - useEffect(() => { - if (!hashTrigger.active) return; - const handler = (e: MouseEvent) => { - if ( - hashPopupRef.current && - !hashPopupRef.current.contains(e.target as Node) && - textareaRef.current && - !textareaRef.current.contains(e.target as Node) - ) { - setHashTrigger({ active: false, startIndex: -1 }); - setHashQuery(""); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [hashTrigger.active]); + // 外部クリックで # ポップアップを閉じる(textarea 内のクリックは除外) + useClickOutside( + [hashPopupRef, textareaRef], + () => { + setHashTrigger({ active: false, startIndex: -1 }); + setHashQuery(""); + }, + hashTrigger.active, + ); // # トリガー: ワークスペースファイルを検索する useEffect(() => { @@ -272,77 +261,23 @@ export function InputArea({ : false; return ( -
    -
    +
    +
    {/* コンテキストバー: クリップボタン + 添付ファイルチップ + quick-add を1行に */} -
    -
    - {/* クリップボタン */} -
    - - {showFilePicker && ( -
    - handleFilePickerSearch(e.target.value)} - /> -
    - {pickerFiles.length > 0 ? ( - pickerFiles.slice(0, 15).map((file) => ( -
    addFile(file)}> - {file.fileName} - {file.filePath} -
    - )) - ) : ( -
    {t["input.noFiles"]}
    - )} -
    -
    - )} -
    - {/* 添付されたファイルチップ (インライン) */} - {attachedFiles.map((file) => ( -
    - {file.fileName} - -
    - ))} - {/* 現在開いているファイルの quick-add ボタン */} - {activeEditorFile && !isActiveAttached && ( - - )} -
    +
    + {/* コンテキストウィンドウ使用率インジケーター (右側) */} {contextLimit > 0 && ( {/* テキスト入力エリア(# ポップアップ付き) */} -
    +