diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d752c..e915cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.5.0] - 2026-03-08 + +### Added + +- Explicit OpenCode skill selection in the chat input via context menu and `/` autocomplete (#98) +- Scroll-to-bottom button for the messages area when viewing older messages (#95) + +### Changed + +- Chat input now loads skill metadata from OpenCode and prepends the selected skill as a synthetic slash command before sending + ## [0.4.2] - 2026-03-08 ### Added @@ -129,7 +140,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Todo display - i18n support (English, Japanese) -[Unreleased]: https://github.com/ktmage/opencode-gui/compare/v0.4.1...HEAD +[Unreleased]: https://github.com/ktmage/opencode-gui/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/ktmage/opencode-gui/compare/v0.4.2...v0.5.0 [0.4.1]: https://github.com/ktmage/opencode-gui/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/ktmage/opencode-gui/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/ktmage/opencode-gui/compare/v0.2.0...v0.3.0 diff --git a/package.json b/package.json index 55dba8e..bd8231e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencodegui-monorepo", "private": true, - "version": "0.4.2", + "version": "0.5.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts index 071eb5a..6e2b80c 100644 --- a/packages/agents/opencode/src/__tests__/opencode-agent.test.ts +++ b/packages/agents/opencode/src/__tests__/opencode-agent.test.ts @@ -46,6 +46,7 @@ function createMockSdkClient() { }, app: { agents: vi.fn().mockResolvedValue({ data: [] }), + skills: vi.fn().mockResolvedValue({ data: [] }), }, mcp: { status: vi.fn().mockResolvedValue({ data: {} }), @@ -366,6 +367,17 @@ describe("OpenCodeAgent", () => { expect(call.parts[1]).toEqual({ type: "agent", name: "code-reviewer" }); }); + it("should prepend synthetic skill command when skill is provided", async () => { + await agent.connect(); + + await agent.sendMessage("sess-1", "Hello", { skill: "coding-guidelines" } as never); + + const call = mockClient.session.promptAsync.mock.calls[0][0]; + expect(call.parts).toHaveLength(2); + expect(call.parts[0]).toEqual({ type: "text", text: "/coding-guidelines", synthetic: true }); + expect(call.parts[1]).toEqual({ type: "text", text: "Hello" }); + }); + it("should include all parts when files and agent are provided", async () => { await agent.connect(); agent.workspaceFolder = "/ws"; @@ -684,6 +696,22 @@ describe("OpenCodeAgent", () => { }); }); + describe("getSkills()", () => { + it("should call app.skills() and return mapped skills", async () => { + mockClient.app.skills.mockResolvedValue({ + data: [{ name: "coding-guidelines", description: "desc", location: "/skills/coding-guidelines" }], + }); + await agent.connect(); + + const result = await agent.getSkills(); + + expect(mockClient.app.skills).toHaveBeenCalled(); + expect(result).toEqual([ + { name: "coding-guidelines", description: "desc", location: "/skills/coding-guidelines" }, + ]); + }); + }); + // ============================================================ // Config API // ============================================================ diff --git a/packages/agents/opencode/src/mappers.ts b/packages/agents/opencode/src/mappers.ts index b395dcd..750dd29 100644 --- a/packages/agents/opencode/src/mappers.ts +++ b/packages/agents/opencode/src/mappers.ts @@ -32,6 +32,7 @@ import type { McpStatus, MessagePart, ProviderInfo, + SkillInfo, TodoItem, ToolListItem, } from "@opencodegui/core"; @@ -129,6 +130,14 @@ export function mapAgents(agents: Agent[]): AgentInfo[] { return agents.map(mapAgent); } +export function mapSkills(skills: Array<{ name: string; description: string; location: string }>): SkillInfo[] { + return skills.map((skill) => ({ + name: skill.name, + description: skill.description, + location: skill.location, + })); +} + // ============================================================ // Config // ============================================================ diff --git a/packages/agents/opencode/src/opencode-agent.ts b/packages/agents/opencode/src/opencode-agent.ts index fcf61ac..9740369 100644 --- a/packages/agents/opencode/src/opencode-agent.ts +++ b/packages/agents/opencode/src/opencode-agent.ts @@ -29,6 +29,7 @@ import type { ProviderInfo, QuestionAnswer, SendMessageOptions, + SkillInfo, TodoItem, ToolListItem, } from "@opencodegui/core"; @@ -44,6 +45,7 @@ import { mapProviders, mapSession, mapSessions, + mapSkills, mapTodos, mapToolIds, } from "./mappers"; @@ -239,10 +241,16 @@ export class OpenCodeAgent implements IAgent { async sendMessage(sessionId: string, text: string, options?: SendMessageOptions): Promise { const client = this.requireClient(); const parts: Array< - | { type: "text"; text: string } + | { type: "text"; text: string; synthetic?: boolean } | { type: "file"; mime: string; url: string; filename: string } | { type: "agent"; name: string } - > = [{ type: "text", text }]; + > = []; + + if (options?.skill) { + parts.push({ type: "text", text: `/${options.skill}`, synthetic: true }); + } + + parts.push({ type: "text", text }); if (options?.files) { for (const file of options.files) { @@ -320,6 +328,12 @@ export class OpenCodeAgent implements IAgent { return mapAgents(response.data!); } + async getSkills(): Promise { + const client = this.requireClient(); + const response = await client.app.skills(); + return mapSkills(response.data!); + } + async getChildSessions(sessionId: string): Promise { const client = this.requireClient(); const response = await client.session.children({ diff --git a/packages/core/src/agent.interface.ts b/packages/core/src/agent.interface.ts index ba89c9f..f8e841e 100644 --- a/packages/core/src/agent.interface.ts +++ b/packages/core/src/agent.interface.ts @@ -22,6 +22,7 @@ import type { ProviderInfo, QuestionAnswer, SendMessageOptions, + SkillInfo, TodoItem, ToolListItem, } from "./domain"; @@ -100,6 +101,7 @@ export interface IAgent { // --- Agent list (capabilities.subAgent) --- getAgents(): Promise; + getSkills(): Promise; getChildSessions(sessionId: string): Promise; // --- Permissions (capabilities.permission) --- diff --git a/packages/core/src/domain.ts b/packages/core/src/domain.ts index 690e878..d825bb6 100644 --- a/packages/core/src/domain.ts +++ b/packages/core/src/domain.ts @@ -438,6 +438,12 @@ export type AgentInfo = { color?: string; }; +export type SkillInfo = { + name: string; + description?: string; + location?: string; +}; + // ============================================================ // App Config & Paths // ============================================================ @@ -460,6 +466,7 @@ export type SendMessageOptions = { files?: FileAttachment[]; agent?: string; primaryAgent?: string; + skill?: string; }; // ============================================================ diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts index 141e29c..cd80c6b 100644 --- a/packages/core/src/protocol.ts +++ b/packages/core/src/protocol.ts @@ -20,6 +20,7 @@ import type { PermissionResponse, ProviderInfo, QuestionAnswer, + SkillInfo, TodoItem, } from "./domain"; @@ -59,6 +60,7 @@ export type UIToHostMessage = files?: FileAttachment[]; agent?: string; primaryAgent?: string; + skill?: string; } | { type: "editAndResend"; @@ -105,6 +107,7 @@ export type UIToHostMessage = | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } | { type: "getAgents" } + | { type: "getSkills" } // --- Model config --- | { type: "setModel"; model: string } @@ -193,6 +196,7 @@ export type HostToUIMessage = // --- Agent list --- | { type: "agents"; agents: AgentInfo[] } + | { type: "skills"; skills: SkillInfo[] } // --- Platform data --- | { type: "openEditors"; files: FileAttachment[] } diff --git a/packages/platforms/vscode/package.json b/packages/platforms/vscode/package.json index 5d64040..368e025 100644 --- a/packages/platforms/vscode/package.json +++ b/packages/platforms/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencodegui", "displayName": "%displayName%", "description": "%description%", - "version": "0.4.2", + "version": "0.5.0", "publisher": "ktmage", "license": "MIT", "repository": { diff --git a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts index 27eb26d..de2bd9a 100644 --- a/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts +++ b/packages/platforms/vscode/src/__tests__/chat-view-provider.test.ts @@ -58,6 +58,7 @@ function createMockAgent(): { getProviders: vi.fn().mockResolvedValue({ providers: [], default: {} }), listAllProviders: vi.fn().mockResolvedValue({ all: [], default: {}, connected: [] }), getAgents: vi.fn().mockResolvedValue([]), + getSkills: vi.fn().mockResolvedValue([]), getChildSessions: vi.fn().mockResolvedValue([]), replyPermission: vi.fn().mockResolvedValue(undefined), getSessionDiff: vi.fn().mockResolvedValue([]), @@ -499,12 +500,14 @@ describe("ChatViewProvider", () => { model: { providerID: "anthropic", modelID: "claude-4" }, files: [{ filePath: "a.ts", fileName: "a.ts" }], agent: "reviewer", + skill: "coding-guidelines", }); expect(mockAgent.sendMessage).toHaveBeenCalledWith("sess-1", "Hello", { model: { providerID: "anthropic", modelID: "claude-4" }, files: [{ filePath: "a.ts", fileName: "a.ts" }], agent: "reviewer", + skill: "coding-guidelines", }); }); }); @@ -909,6 +912,18 @@ describe("ChatViewProvider", () => { }); }); + describe("getSkills", () => { + it("should send skills message", async () => { + const skills = [{ name: "coding-guidelines" }]; + mockAgent.getSkills.mockResolvedValue(skills as never); + + const { postMessage, sendMessage } = setupProvider(mockAgent); + await sendMessage({ type: "getSkills" }); + + expect(postMessage).toHaveBeenCalledWith({ type: "skills", skills }); + }); + }); + // ============================================================ // shareSession // ============================================================ diff --git a/packages/platforms/vscode/src/chat-view-provider.ts b/packages/platforms/vscode/src/chat-view-provider.ts index 6184516..0dc4f21 100644 --- a/packages/platforms/vscode/src/chat-view-provider.ts +++ b/packages/platforms/vscode/src/chat-view-provider.ts @@ -103,6 +103,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { files: message.files, agent: message.agent, primaryAgent: message.primaryAgent, + skill: message.skill, }); break; } @@ -266,6 +267,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "agents", agents }); break; } + case "getSkills": { + const skills = await this.agent.getSkills(); + this.postMessage({ type: "skills", skills }); + break; + } case "shareSession": { const session = await this.agent.shareSession(message.sessionId); this.activeSession = session; diff --git a/packages/platforms/vscode/webview/App.tsx b/packages/platforms/vscode/webview/App.tsx index 7fc7671..1effc02 100644 --- a/packages/platforms/vscode/webview/App.tsx +++ b/packages/platforms/vscode/webview/App.tsx @@ -1,4 +1,4 @@ -import type { AgentEvent, AgentInfo, ChatSession, TodoItem } from "@opencodegui/core"; +import type { AgentEvent, AgentInfo, ChatSession, SkillInfo, TodoItem } from "@opencodegui/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { EmptyState } from "./components/molecules/EmptyState"; import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; @@ -47,6 +47,7 @@ export function App() { const [todos, setTodos] = useState([]); const [childSessions, setChildSessions] = useState([]); const [agents, setAgents] = useState([]); + const [skills, setSkills] = useState([]); const [selectedPrimaryAgent, setSelectedPrimaryAgent] = useState(null); const [difitAvailable, setDifitAvailable] = useState(false); const [openCodePaths, setOpenCodePaths] = useState<{ @@ -208,6 +209,10 @@ export function App() { }); break; } + case "skills": { + setSkills(data.skills); + break; + } case "difitAvailable": { setDifitAvailable(data.available); break; @@ -218,6 +223,7 @@ export function App() { postMessage({ type: "ready" }); postMessage({ type: "getOpenEditors" }); postMessage({ type: "getAgents" }); + postMessage({ type: "getSkills" }); return () => window.removeEventListener("message", handler); }, [ session.activeSession?.id, @@ -236,7 +242,7 @@ export function App() { // Cross-cutting action handlers (span multiple hooks) const handleSend = useCallback( - (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string) => { + (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string, skill?: string) => { if (!session.activeSession) return; postMessage({ type: "sendMessage", @@ -246,6 +252,7 @@ export function App() { files: files.length > 0 ? files : undefined, agent, primaryAgent, + skill, }); }, [session.activeSession, prov.selectedModel], @@ -538,6 +545,7 @@ export function App() { soundSettings={sound.soundSettings} onSoundSettingChange={sound.handleSoundSettingChange} agents={agents} + skills={skills} /> )} diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx index d659e83..1aa2815 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/FileAttachmentBar.test.tsx @@ -30,6 +30,10 @@ const defaultProps = { selectedAgent: null, onSelectAgent: vi.fn(), onClearAgent: vi.fn(), + skills: [] as any[], + selectedSkill: null, + onSelectSkill: vi.fn(), + onClearSkill: vi.fn(), isShellMode: false, onToggleShellMode: vi.fn(), onDisableShellMode: vi.fn(), @@ -105,13 +109,20 @@ describe("FileAttachmentBar", () => { expect(container.querySelector(".pickerDropdown")).toBeInTheDocument(); }); - // renders three sections: Files, Agents, Shell Mode - it("3 つのセクション(Files, Agents, Shell Mode)を表示すること", () => { + // renders four sections: Files, Agents, Skills, Shell Mode + it("4 つのセクション(Files, Agents, Skills, Shell Mode)を表示すること", () => { render( - , + , ); expect(screen.getByText("Files")).toBeInTheDocument(); expect(screen.getByText("Sub-agents")).toBeInTheDocument(); + expect(screen.getByText("Skills")).toBeInTheDocument(); expect(screen.getByText("Shell Mode")).toBeInTheDocument(); }); }); @@ -161,7 +172,7 @@ describe("FileAttachmentBar", () => { />, ); const disabledSections = container.querySelectorAll(".sectionDisabled"); - expect(disabledSections.length).toBe(2); + expect(disabledSections.length).toBe(3); }); // toggle track has toggleOn class diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx new file mode 100644 index 0000000..e1c13b2 --- /dev/null +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/SkillPopup.test.tsx @@ -0,0 +1,51 @@ +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SkillPopup } from "../../../components/molecules/SkillPopup"; + +function createSkill(name: string, description?: string) { + return { + name, + description, + location: `/skills/${name}`, + }; +} + +describe("SkillPopup", () => { + context("スキル一覧がある場合", () => { + const skills = [createSkill("coding-guidelines", "Coding skill"), createSkill("manage-task-plan", "Task plan")]; + + it("スキル名を表示すること", () => { + const { container } = render( + , + ); + const titles = container.querySelectorAll(".title"); + expect(titles[0]?.textContent).toBe("coding-guidelines"); + expect(titles[1]?.textContent).toBe("manage-task-plan"); + }); + + it("クリックで onSelectSkill を呼ぶこと", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const items = container.querySelectorAll(".root > div"); + await user.click(items[0]!); + expect(onSelect).toHaveBeenCalledWith(skills[0]); + }); + }); + + context("focusedIndex が指定された場合", () => { + const skills = [createSkill("coding-guidelines", "Coding skill"), createSkill("manage-task-plan", "Task plan")]; + + it("対応するアイテムに data-focused 属性が付与されること", () => { + const { container } = render( + , + ); + const items = container.querySelectorAll("[data-focused]"); + expect(items).toHaveLength(1); + expect(items[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); +}); diff --git a/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx b/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx index d5cde50..68cdb08 100644 --- a/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -1,10 +1,12 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { MessageWithParts } from "../../../App"; import { MessagesArea } from "../../../components/organisms/MessagesArea"; import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; +import * as autoScrollHook from "../../../hooks/useAutoScroll"; import { createMessage, createTextPart } from "../../factories"; /** AppContext 必須の値を最小限で提供するラッパー */ @@ -40,6 +42,10 @@ const defaultProps = { describe("MessagesArea", () => { const wrapper = createContextWrapper(); + afterEach(() => { + vi.restoreAllMocks(); + }); + // when rendered with messages context("メッセージがある場合", () => { // renders message items @@ -135,5 +141,41 @@ describe("MessagesArea", () => { const scrollContainer = container.querySelector(".root") as HTMLElement; expect(scrollContainer).toBeInTheDocument(); }); + + it("最下部から離れているときにスクロールボタンを表示すること", () => { + const handleScroll = vi.fn(); + const scrollToBottom = vi.fn(); + + vi.spyOn(autoScrollHook, "useAutoScroll").mockReturnValue({ + containerRef: { current: null }, + bottomRef: { current: null }, + handleScroll, + isNearBottom: false, + scrollToBottom, + }); + + render(, { wrapper }); + + expect(screen.getByLabelText("Scroll to bottom")).toBeInTheDocument(); + }); + + it("スクロールボタンクリックで scrollToBottom を呼ぶこと", async () => { + const handleScroll = vi.fn(); + const scrollToBottom = vi.fn(); + + vi.spyOn(autoScrollHook, "useAutoScroll").mockReturnValue({ + containerRef: { current: null }, + bottomRef: { current: null }, + handleScroll, + isNearBottom: false, + scrollToBottom, + }); + + render(, { wrapper }); + + await userEvent.click(screen.getByLabelText("Scroll to bottom")); + + expect(scrollToBottom).toHaveBeenCalledWith(); + }); }); }); diff --git a/packages/platforms/vscode/webview/__tests__/factories.ts b/packages/platforms/vscode/webview/__tests__/factories.ts index c58d106..50b9d5b 100644 --- a/packages/platforms/vscode/webview/__tests__/factories.ts +++ b/packages/platforms/vscode/webview/__tests__/factories.ts @@ -152,7 +152,7 @@ export function createAllProvidersData( connected: string[] = [], all: Array<{ id: string; name: string; models: Record }> = [], defaultModel: Record = {}, -) { +): any { return { connected, all: all.map((p) => ({ diff --git a/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts b/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts index 5414cf1..8121ced 100644 --- a/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts +++ b/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts @@ -17,6 +17,16 @@ describe("useAutoScroll", () => { const { result } = renderHook(() => useAutoScroll([])); expect(typeof result.current.handleScroll).toBe("function"); }); + + it("isNearBottom がデフォルトで true であること", () => { + const { result } = renderHook(() => useAutoScroll([])); + expect(result.current.isNearBottom).toBe(true); + }); + + it("scrollToBottom コールバックを返すこと", () => { + const { result } = renderHook(() => useAutoScroll([])); + expect(typeof result.current.scrollToBottom).toBe("function"); + }); }); // on mount @@ -97,6 +107,7 @@ describe("useAutoScroll", () => { rerender({ messages: ["msg1", "msg2"] }); expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(result.current.isNearBottom).toBe(false); }); }); @@ -137,6 +148,24 @@ describe("useAutoScroll", () => { // メッセージ更新 rerender({ messages: ["msg1", "msg2", "msg3"] }); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth" }); + expect(result.current.isNearBottom).toBe(true); + }); + }); + + context("scrollToBottom を明示的に呼び出した場合", () => { + it("scrollIntoView が呼ばれること", () => { + const scrollIntoViewMock = vi.fn(); + const { result } = renderHook(() => useAutoScroll([])); + + const bottomEl = document.createElement("div"); + bottomEl.scrollIntoView = scrollIntoViewMock; + (result.current.bottomRef as React.MutableRefObject).current = bottomEl; + + act(() => { + result.current.scrollToBottom(); + }); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth" }); }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx index 0c4e6ad..0158795 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/01-initialization.test.tsx @@ -1,6 +1,6 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { postMessage } from "../../vscode-api"; import { createAllProvidersData, createProvider, createSession } from "../factories"; import { renderApp, sendExtMessage } from "../helpers"; @@ -22,6 +22,12 @@ describe("初期化", () => { expect(postMessage).toHaveBeenCalledWith({ type: "getOpenEditors" }); }); + + it("getSkills を送信すること", () => { + renderApp(); + + expect(postMessage).toHaveBeenCalledWith({ type: "getSkills" }); + }); }); // When sessions message is received diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx index af3e5a5..33475aa 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/21-popup-tab-select.test.tsx @@ -27,6 +27,19 @@ const testAgents = [ }, ] as any; +const testSkills = [ + { + name: "coding-guidelines", + description: "Coding skill", + location: "/skills/coding-guidelines", + }, + { + name: "manage-task-plan", + description: "Task plan skill", + location: "/skills/manage-task-plan", + }, +] as any; + /** ファイル候補付きセットアップ */ async function setupWithFiles() { renderApp(); @@ -47,6 +60,7 @@ async function setupWithFiles() { ], }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); vi.mocked(postMessage).mockClear(); } @@ -259,4 +273,28 @@ describe("ポップアップの Tab 選択", () => { expect(popup?.querySelectorAll(":scope > div")[0]?.getAttribute("data-focused")).toBe("true"); }); }); + + context("/ ポップアップで Tab を押した場合", () => { + 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='skill-popup']"); + const items = popup?.querySelectorAll(":scope > div"); + expect(items?.[0]?.getAttribute("data-focused")).toBe("true"); + }); + }); + + context("/ ポップアップで Enter を押した場合", () => { + 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("skill-popup")).not.toBeInTheDocument(); + expect(screen.getByText("/coding-guidelines")).toBeInTheDocument(); + }); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx b/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx index e023f01..da86304 100644 --- a/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/scenarios/23-clip-context-menu.test.tsx @@ -27,11 +27,20 @@ const testAgents = [ }, ] as any; +const testSkills = [ + { + name: "coding-guidelines", + description: "Code with project guidelines", + location: "/skills/coding-guidelines", + }, +] as any; + /** エージェント付きセットアップ */ async function setupWithAgents() { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); vi.mocked(postMessage).mockClear(); } @@ -40,6 +49,7 @@ async function setupWithFiles() { renderApp(); await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) }); await sendExtMessage({ type: "agents", agents: testAgents }); + await sendExtMessage({ type: "skills", skills: testSkills }); // openEditors はファイルピッカー表示時に使う await sendExtMessage({ type: "openEditors", @@ -64,13 +74,14 @@ describe("統合コンテキストメニュー", () => { await setupWithAgents(); }); - // shows file, agent, and shell sections - it("ファイル・エージェント・シェルモードの 3 セクションが表示されること", async () => { + // shows file, agent, skill, and shell sections + it("ファイル・エージェント・スキル・シェルモードの 4 セクションが表示されること", async () => { const user = userEvent.setup(); const clipButton = screen.getByTitle("Add context"); await user.click(clipButton); expect(screen.getByText("Files")).toBeInTheDocument(); expect(screen.getByText("Sub-agents")).toBeInTheDocument(); + expect(screen.getByText("Skills")).toBeInTheDocument(); expect(screen.getByText("Shell Mode")).toBeInTheDocument(); }); }); @@ -106,6 +117,34 @@ describe("統合コンテキストメニュー", () => { }); }); + context("統合メニューからスキルを選択した場合", () => { + beforeEach(async () => { + await setupWithAgents(); + }); + + it("スキルチップが contextBar に表示されること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Add context")); + await user.click(screen.getByText("coding-guidelines")); + expect(screen.getByText("/coding-guidelines")).toBeInTheDocument(); + }); + + it("送信時に skill が含まれること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Add context")); + await user.click(screen.getByText("coding-guidelines")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + await user.type(textarea, "Fix the bug{Enter}"); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendMessage", + text: "Fix the bug", + skill: "coding-guidelines", + }), + ); + }); + }); + // Toggling shell mode from the menu context("統合メニューでシェルモードを ON にした場合", () => { beforeEach(async () => { diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css new file mode 100644 index 0000000..bc163de --- /dev/null +++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css @@ -0,0 +1,29 @@ +.root { + pointer-events: none; + opacity: 0; + transform: translateY(8px); + transition: + opacity 0.18s ease, + transform 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + background-color 0.18s ease; + background-color: var(--vscode-editorWidget-background); + border-color: var(--vscode-editorWidget-border, var(--vscode-panel-border)); + box-shadow: 0 6px 16px rgb(0 0 0 / 20%); +} + +.visible { + pointer-events: auto; + opacity: 1; + transform: translateY(0); +} + +.root:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.root:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx new file mode 100644 index 0000000..09e31f4 --- /dev/null +++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx @@ -0,0 +1,25 @@ +import { IconButton } from "../IconButton"; +import { ChevronDownIcon } from "../icons"; +import styles from "./ScrollToBottomButton.module.css"; + +type Props = { + visible: boolean; + ariaLabel: string; + onClick: () => void; +}; + +export function ScrollToBottomButton({ visible, ariaLabel, onClick }: Props) { + return ( + + + + ); +} diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts new file mode 100644 index 0000000..9e8a127 --- /dev/null +++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts @@ -0,0 +1 @@ +export { ScrollToBottomButton } from "./ScrollToBottomButton"; diff --git a/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx b/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx index 4142f23..0d43185 100644 --- a/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx +++ b/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx @@ -2,6 +2,7 @@ import type { SVGProps } from "react"; import type { IconBaseProps } from "react-icons"; import { VscAdd, + VscArrowDown, VscArrowLeft, VscArrowRight, VscAttach, @@ -256,3 +257,8 @@ export function UndoIcon({ width = 16, height: _h, ...props }: IconProps) { export function RedoIcon({ width = 16, height: _h, ...props }: IconProps) { return ; } + +/** Codicon: arrow-down — scroll to bottom */ +export function ChevronDownIcon({ width = 16, height: _h, ...props }: IconProps) { + return ; +} diff --git a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css index e6ad51d..4985a73 100644 --- a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css +++ b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.module.css @@ -268,3 +268,38 @@ .agentChipClear:hover { opacity: 1; } + +.skillChip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 6px; + font-size: 11px; + line-height: 1; + color: var(--vscode-terminal-ansiGreen, #98c379); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-terminal-ansiGreen, #98c379); + border-radius: 4px; +} + +.skillChipName { + font-weight: 600; +} + +.skillChipClear { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.skillChipClear:hover { + opacity: 1; +} diff --git a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx index 62b0d47..8daf58c 100644 --- a/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx +++ b/packages/platforms/vscode/webview/components/molecules/FileAttachmentBar/FileAttachmentBar.tsx @@ -1,9 +1,9 @@ -import type { AgentInfo } from "@opencodegui/core"; +import type { AgentInfo, SkillInfo } from "@opencodegui/core"; import { useLocale } from "../../../locales"; import { getFileIcon } from "../../../utils/file-icons"; import type { FileAttachment } from "../../../vscode-api"; import { IconButton } from "../../atoms/IconButton"; -import { AgentIcon, ClipIcon, CloseIcon, PlusIcon, TerminalIcon } from "../../atoms/icons"; +import { AgentIcon, ClipIcon, CloseIcon, GearIcon, PlusIcon, TerminalIcon } from "../../atoms/icons"; import { ListItem } from "../../atoms/ListItem"; import styles from "./FileAttachmentBar.module.css"; @@ -23,6 +23,10 @@ type Props = { selectedAgent: AgentInfo | null; onSelectAgent: (agent: AgentInfo) => void; onClearAgent: () => void; + skills: SkillInfo[]; + selectedSkill: SkillInfo | null; + onSelectSkill: (skill: SkillInfo) => void; + onClearSkill: () => void; isShellMode: boolean; onToggleShellMode: () => void; onDisableShellMode: () => void; @@ -44,6 +48,10 @@ export function FileAttachmentBar({ selectedAgent, onSelectAgent, onClearAgent, + skills, + selectedSkill, + onSelectSkill, + onClearSkill, isShellMode, onToggleShellMode, onDisableShellMode, @@ -116,6 +124,28 @@ export function FileAttachmentBar({ + {/* スキルセクション */} +
+
+
{t["input.section.skills"]}
+
+ {skills.length > 0 ? ( + skills.map((skill) => ( + } + onClick={() => onSelectSkill(skill)} + focused={selectedSkill?.name === skill.name} + /> + )) + ) : ( +
{t["input.noSkills"]}
+ )} +
+
+ {/* シェルモードセクション */}
{t["input.section.shell"]}
@@ -149,6 +179,15 @@ export function FileAttachmentBar({
)} + {selectedSkill && ( +
+ + /{selectedSkill.name} + +
+ )} {/* 添付されたファイルチップ (インライン) */} {attachedFiles.map((file) => { const ChipIcon = getFileIcon(file.fileName); diff --git a/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.module.css b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.module.css new file mode 100644 index 0000000..f8ba5c0 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.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/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx new file mode 100644 index 0000000..4b0efb8 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/SkillPopup.tsx @@ -0,0 +1,35 @@ +import type { SkillInfo } from "@opencodegui/core"; +import { useLocale } from "../../../locales"; +import { GearIcon } from "../../atoms/icons"; +import { ListItem } from "../../atoms/ListItem"; +import styles from "./SkillPopup.module.css"; + +type Props = { + skills: SkillInfo[]; + onSelectSkill: (skill: SkillInfo) => void; + skillPopupRef: React.RefObject; + focusedIndex: number; +}; + +export function SkillPopup({ skills, onSelectSkill, skillPopupRef, focusedIndex }: Props) { + const t = useLocale(); + + return ( +
+ {skills.length > 0 ? ( + skills.map((skill, i) => ( + } + onClick={() => onSelectSkill(skill)} + focused={i === focusedIndex} + /> + )) + ) : ( +
{t["input.noSkills"]}
+ )} +
+ ); +} diff --git a/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts b/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts new file mode 100644 index 0000000..4a6f4b1 --- /dev/null +++ b/packages/platforms/vscode/webview/components/molecules/SkillPopup/index.ts @@ -0,0 +1 @@ +export { SkillPopup } from "./SkillPopup"; diff --git a/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx b/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx index b46ce04..3607753 100644 --- a/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx +++ b/packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx @@ -1,4 +1,11 @@ -import type { AgentInfo, ProviderInfo, SoundEventSetting, SoundEventType, SoundSettings } from "@opencodegui/core"; +import type { + AgentInfo, + ProviderInfo, + SkillInfo, + SoundEventSetting, + SoundEventType, + SoundSettings, +} from "@opencodegui/core"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import { useClickOutside } from "../../../hooks/useClickOutside"; import { useInputHistory } from "../../../hooks/useInputHistory"; @@ -14,11 +21,12 @@ import { AgentSelector } from "../../molecules/AgentSelector"; import { FileAttachmentBar } from "../../molecules/FileAttachmentBar"; import { HashFilePopup } from "../../molecules/HashFilePopup"; import { ModelSelector } from "../../molecules/ModelSelector"; +import { SkillPopup } from "../../molecules/SkillPopup"; import { ToolConfigPanel } from "../../organisms/ToolConfigPanel"; import styles from "./InputArea.module.css"; type Props = { - onSend: (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string) => void; + onSend: (text: string, files: FileAttachment[], agent?: string, primaryAgent?: string, skill?: string) => void; onShellExecute: (command: string) => void; onAbort: () => void; isBusy: boolean; @@ -41,6 +49,7 @@ type Props = { soundSettings: SoundSettings; onSoundSettingChange: (eventType: SoundEventType, setting: Partial) => void; agents: AgentInfo[]; + skills: SkillInfo[]; }; export function InputArea({ @@ -67,6 +76,7 @@ export function InputArea({ soundSettings, onSoundSettingChange, agents, + skills, }: Props) { const t = useLocale(); const [text, setText] = useState(""); @@ -85,16 +95,24 @@ export function InputArea({ startIndex: -1, }); const [atQuery, setAtQuery] = useState(""); + const [slashTrigger, setSlashTrigger] = useState<{ active: boolean; startIndex: number }>({ + active: false, + startIndex: -1, + }); + const [slashQuery, setSlashQuery] = useState(""); const [selectedAgent, setSelectedAgent] = useState(null); + const [selectedSkill, setSelectedSkill] = useState(null); const [isShellMode, setIsShellMode] = useState(false); // ポップアップ内のフォーカス位置(-1 = フォーカスなし) const [hashFocusedIndex, setHashFocusedIndex] = useState(-1); const [atFocusedIndex, setAtFocusedIndex] = useState(-1); + const [slashFocusedIndex, setSlashFocusedIndex] = useState(-1); const textareaRef = useRef(null); const composingRef = useRef(false); const filePickerRef = useRef(null); const hashPopupRef = useRef(null); const agentPopupRef = useRef(null); + const skillPopupRef = useRef(null); const inputHistory = useInputHistory(); // 履歴テキスト適用時は onChange が走るが resetNavigation を呼ばないようにするフラグ const applyingHistoryRef = useRef(false); @@ -183,6 +201,16 @@ export function InputArea({ atTrigger.active, ); + useClickOutside( + [skillPopupRef, textareaRef], + () => { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + }, + slashTrigger.active, + ); + // # トリガー: ワークスペースファイルを検索する useEffect(() => { if (hashTrigger.active) { @@ -195,6 +223,7 @@ export function InputArea({ setIsShellMode(true); setAttachedFiles([]); setSelectedAgent(null); + setSelectedSkill(null); }, []); // シェルモード OFF @@ -237,6 +266,29 @@ export function InputArea({ setSelectedAgent(null); }, []); + const selectSkill = useCallback( + (skill: SkillInfo) => { + setSelectedSkill(skill); + setIsShellMode(false); + if (slashTrigger.active) { + setText((prev) => { + const before = prev.slice(0, slashTrigger.startIndex); + const after = prev.slice(slashTrigger.startIndex + 1 + slashQuery.length); + return before + after; + }); + } + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + textareaRef.current?.focus(); + }, + [slashQuery, slashTrigger], + ); + + const clearSkill = useCallback(() => { + setSelectedSkill(null); + }, []); + const handleSend = useCallback(() => { const trimmed = text.trim(); if (!trimmed) return; @@ -245,11 +297,12 @@ export function InputArea({ if (isShellMode) { onShellExecute(trimmed); } else { - onSend(trimmed, attachedFiles, selectedAgent?.name, selectedPrimaryAgent ?? undefined); + onSend(trimmed, attachedFiles, selectedAgent?.name, selectedPrimaryAgent ?? undefined, selectedSkill?.name); } setText(""); setAttachedFiles([]); setSelectedAgent(null); + setSelectedSkill(null); setIsShellMode(false); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -260,6 +313,7 @@ export function InputArea({ onSend, onShellExecute, selectedAgent?.name, + selectedSkill?.name, isShellMode, inputHistory, selectedPrimaryAgent, @@ -284,6 +338,9 @@ export function InputArea({ const filteredAgents = atQuery ? subagents.filter((a) => a.name.toLowerCase().includes(atQuery.toLowerCase())).slice(0, 10) : subagents.slice(0, 10); + const filteredSkills = slashQuery + ? skills.filter((skill) => skill.name.toLowerCase().includes(slashQuery.toLowerCase())).slice(0, 10) + : skills.slice(0, 10); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -301,6 +358,12 @@ export function InputArea({ setAtFocusedIndex(-1); return; } + if (e.key === "Escape" && slashTrigger.active) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + return; + } // # ポップアップ表示中の Tab / ↑ / ↓ / Enter ナビゲーション if (hashTrigger.active && hashFiles.length > 0) { @@ -346,6 +409,24 @@ export function InputArea({ } } + if (slashTrigger.active && filteredSkills.length > 0) { + if (e.key === "Tab" || e.key === "ArrowDown") { + e.preventDefault(); + setSlashFocusedIndex((prev) => (prev + 1) % filteredSkills.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashFocusedIndex((prev) => (prev <= 0 ? filteredSkills.length - 1 : prev - 1)); + return; + } + if (e.key === "Enter" && !e.shiftKey && !composingRef.current && slashFocusedIndex >= 0) { + e.preventDefault(); + selectSkill(filteredSkills[slashFocusedIndex]); + return; + } + } + // ArrowUp: カーソルが先頭行にあるとき履歴を遡る if (e.key === "ArrowUp" && !composingRef.current) { const el = textareaRef.current; @@ -417,6 +498,11 @@ export function InputArea({ setAtQuery(""); setAtFocusedIndex(-1); } + if (slashTrigger.active) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + } handleSend(); } }, @@ -427,10 +513,14 @@ export function InputArea({ atTrigger.active, hashFocusedIndex, atFocusedIndex, + slashTrigger.active, + slashFocusedIndex, hashFiles, filteredAgents, + filteredSkills, addFile, selectAgent, + selectSkill, text, inputHistory, ], @@ -478,6 +568,14 @@ export function InputArea({ setAtQuery(""); return; } + if ( + addedChar === "/" && + (cursorPos === 1 || newText[cursorPos - 2] === " " || newText[cursorPos - 2] === "\n") + ) { + setSlashTrigger({ active: true, startIndex: cursorPos - 1 }); + setSlashQuery(""); + return; + } } // # トリガーがアクティブなら、クエリを更新する @@ -508,8 +606,20 @@ export function InputArea({ setAtFocusedIndex(-1); } } + + if (slashTrigger.active) { + const queryPart = newText.slice(slashTrigger.startIndex + 1, cursorPos); + if (/\s/.test(queryPart) || cursorPos <= slashTrigger.startIndex) { + setSlashTrigger({ active: false, startIndex: -1 }); + setSlashQuery(""); + setSlashFocusedIndex(-1); + } else { + setSlashQuery(queryPart); + setSlashFocusedIndex(-1); + } + } }, - [text, hashTrigger, atTrigger, isShellMode, enableShellMode, inputHistory], + [text, hashTrigger, atTrigger, slashTrigger, isShellMode, enableShellMode, inputHistory], ); const handleInput = useCallback(() => { @@ -552,6 +662,10 @@ export function InputArea({ selectedAgent={selectedAgent} onSelectAgent={selectAgent} onClearAgent={clearAgent} + skills={skills} + selectedSkill={selectedSkill} + onSelectSkill={selectSkill} + onClearSkill={clearSkill} isShellMode={isShellMode} onToggleShellMode={toggleShellMode} onDisableShellMode={disableShellMode} @@ -595,6 +709,14 @@ export function InputArea({ focusedIndex={atFocusedIndex} /> )} + {slashTrigger.active && ( + + )}
diff --git a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css index 2c846fc..375b043 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css +++ b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css @@ -4,6 +4,7 @@ overflow-y: auto; overscroll-behavior-x: none; padding: 12px; + position: relative; } .root::-webkit-scrollbar { @@ -66,3 +67,15 @@ color: var(--vscode-foreground); background-color: var(--vscode-toolbar-hoverBackground); } + +.scrollButtonSlot { + position: sticky; + bottom: 12px; + display: flex; + justify-content: flex-end; + padding-right: 14px; + margin-top: -44px; + margin-bottom: 12px; + pointer-events: none; + z-index: 1; +} diff --git a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx index 7319ea3..ebbe1ad 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx +++ b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx @@ -3,6 +3,7 @@ import type { MessageWithParts } from "../../../App"; import { useAutoScroll } from "../../../hooks/useAutoScroll"; import { useLocale } from "../../../locales"; import { ForkIcon, RevertIcon } from "../../atoms/icons"; +import { ScrollToBottomButton } from "../../atoms/ScrollToBottomButton"; import { StreamingIndicator } from "../../atoms/StreamingIndicator"; import { MessageItem } from "../MessageItem"; import styles from "./MessagesArea.module.css"; @@ -27,7 +28,7 @@ export function MessagesArea({ onForkFromCheckpoint, }: Props) { const t = useLocale(); - const { containerRef, bottomRef, handleScroll } = useAutoScroll(messages); + const { containerRef, bottomRef, handleScroll, isNearBottom, scrollToBottom } = useAutoScroll(messages); return (
@@ -85,6 +86,13 @@ export function MessagesArea({
); })} +
+ scrollToBottom()} + /> +
{sessionBusy && }
diff --git a/packages/platforms/vscode/webview/hooks/useAutoScroll.ts b/packages/platforms/vscode/webview/hooks/useAutoScroll.ts index 1a7c40c..cba6766 100644 --- a/packages/platforms/vscode/webview/hooks/useAutoScroll.ts +++ b/packages/platforms/vscode/webview/hooks/useAutoScroll.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; /** ユーザーが「最下部付近」と判定するスクロール閾値(px) */ const NEAR_BOTTOM_THRESHOLD = 100; @@ -14,26 +14,33 @@ export function useAutoScroll(messages: unknown[]) { const containerRef = useRef(null); const bottomRef = useRef(null); const isNearBottomRef = useRef(true); + const [isNearBottom, setIsNearBottom] = useState(true); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + bottomRef.current?.scrollIntoView({ behavior }); + }, []); const handleScroll = useCallback(() => { const el = containerRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - isNearBottomRef.current = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD; + const nextIsNearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD; + isNearBottomRef.current = nextIsNearBottom; + setIsNearBottom(nextIsNearBottom); }, []); // 初回マウント時に最下部へスクロールする useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, []); + scrollToBottom(); + }, [scrollToBottom]); // messages 更新時、最下部付近にいれば追従スクロールする // biome-ignore lint/correctness/useExhaustiveDependencies: messages の参照変化を検知して effect を再実行する意図的な依存 useEffect(() => { if (isNearBottomRef.current) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + scrollToBottom(); } - }, [messages]); + }, [messages, scrollToBottom]); - return { containerRef, bottomRef, handleScroll } as const; + return { containerRef, bottomRef, handleScroll, isNearBottom, scrollToBottom } as const; } diff --git a/packages/platforms/vscode/webview/locales/en.ts b/packages/platforms/vscode/webview/locales/en.ts index be0c411..d9499c0 100644 --- a/packages/platforms/vscode/webview/locales/en.ts +++ b/packages/platforms/vscode/webview/locales/en.ts @@ -36,6 +36,7 @@ export const en = { "checkpoint.revertTitle": "Revert to this point", "checkpoint.retryFromHere": "Retry from here", "checkpoint.forkFromHere": "Fork from here", + "scrollToBottom.ariaLabel": "Scroll to bottom", // Undo/Redo "header.undo": "Undo", @@ -142,10 +143,16 @@ export const en = { // Context menu sections "input.section.files": "Files", "input.section.agents": "Sub-agents", + "input.section.skills": "Skills", "input.section.shell": "Shell Mode", // AgentMention "input.noAgents": "No sub-agents available", + "input.noSkills": "No skills available", } as const; +export type LocaleSchema = { + [K in keyof typeof en]: (typeof en)[K] extends (...args: infer Args) => string ? (...args: Args) => string : string; +}; + export type LocaleKeys = keyof typeof en; diff --git a/packages/platforms/vscode/webview/locales/es.ts b/packages/platforms/vscode/webview/locales/es.ts index 7a8a09c..def2883 100644 --- a/packages/platforms/vscode/webview/locales/es.ts +++ b/packages/platforms/vscode/webview/locales/es.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const es: typeof en = { +export const es: LocaleSchema = { // ChatHeader "header.sessions": "Sesiones", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const es: typeof en = { "checkpoint.revertTitle": "Revertir a este punto", "checkpoint.retryFromHere": "Reintentar desde aquí", "checkpoint.forkFromHere": "Bifurcar desde aquí", + "scrollToBottom.ariaLabel": "Desplazarse al final", // Undo/Redo "header.undo": "Deshacer", diff --git a/packages/platforms/vscode/webview/locales/ja.ts b/packages/platforms/vscode/webview/locales/ja.ts index 77c2b1f..51399e3 100644 --- a/packages/platforms/vscode/webview/locales/ja.ts +++ b/packages/platforms/vscode/webview/locales/ja.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const ja: typeof en = { +export const ja: LocaleSchema = { // ChatHeader "header.sessions": "セッション一覧", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const ja: typeof en = { "checkpoint.revertTitle": "ここまで巻き戻す", "checkpoint.retryFromHere": "ここからやり直す", "checkpoint.forkFromHere": "ここから分岐", + "scrollToBottom.ariaLabel": "一番下までスクロール", // Undo/Redo "header.undo": "元に戻す", @@ -144,8 +145,10 @@ export const ja: typeof en = { // Context menu sections "input.section.files": "ファイル", "input.section.agents": "サブエージェント", + "input.section.skills": "スキル", "input.section.shell": "シェルモード", // AgentMention "input.noAgents": "利用可能なサブエージェントがありません", + "input.noSkills": "利用可能なスキルがありません", }; diff --git a/packages/platforms/vscode/webview/locales/ko.ts b/packages/platforms/vscode/webview/locales/ko.ts index 610c52c..c112eca 100644 --- a/packages/platforms/vscode/webview/locales/ko.ts +++ b/packages/platforms/vscode/webview/locales/ko.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const ko: typeof en = { +export const ko: LocaleSchema = { // ChatHeader "header.sessions": "세션 목록", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const ko: typeof en = { "checkpoint.revertTitle": "이 지점으로 되돌리기", "checkpoint.retryFromHere": "여기서 다시 시도", "checkpoint.forkFromHere": "여기서 분기", + "scrollToBottom.ariaLabel": "맨 아래로 스크롤", // Undo/Redo "header.undo": "실행 취소", diff --git a/packages/platforms/vscode/webview/locales/pt-br.ts b/packages/platforms/vscode/webview/locales/pt-br.ts index 689bbe7..34d02c9 100644 --- a/packages/platforms/vscode/webview/locales/pt-br.ts +++ b/packages/platforms/vscode/webview/locales/pt-br.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const ptBr: typeof en = { +export const ptBr: LocaleSchema = { // ChatHeader "header.sessions": "Sessões", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const ptBr: typeof en = { "checkpoint.revertTitle": "Reverter para este ponto", "checkpoint.retryFromHere": "Tentar novamente daqui", "checkpoint.forkFromHere": "Ramificar daqui", + "scrollToBottom.ariaLabel": "Rolar até o fim", // Undo/Redo "header.undo": "Desfazer", diff --git a/packages/platforms/vscode/webview/locales/ru.ts b/packages/platforms/vscode/webview/locales/ru.ts index 9aa212c..c2a5f87 100644 --- a/packages/platforms/vscode/webview/locales/ru.ts +++ b/packages/platforms/vscode/webview/locales/ru.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const ru: typeof en = { +export const ru: LocaleSchema = { // ChatHeader "header.sessions": "Сессии", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const ru: typeof en = { "checkpoint.revertTitle": "Вернуться к этой точке", "checkpoint.retryFromHere": "Повторить отсюда", "checkpoint.forkFromHere": "Ответвить отсюда", + "scrollToBottom.ariaLabel": "Прокрутить вниз", // Undo/Redo "header.undo": "Отменить", diff --git a/packages/platforms/vscode/webview/locales/zh-cn.ts b/packages/platforms/vscode/webview/locales/zh-cn.ts index 0c1646d..1201245 100644 --- a/packages/platforms/vscode/webview/locales/zh-cn.ts +++ b/packages/platforms/vscode/webview/locales/zh-cn.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const zhCn: typeof en = { +export const zhCn: LocaleSchema = { // ChatHeader "header.sessions": "会话列表", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const zhCn: typeof en = { "checkpoint.revertTitle": "回退到此处", "checkpoint.retryFromHere": "从此处重试", "checkpoint.forkFromHere": "从此处分支", + "scrollToBottom.ariaLabel": "滚动到底部", // Undo/Redo "header.undo": "撤销", diff --git a/packages/platforms/vscode/webview/locales/zh-tw.ts b/packages/platforms/vscode/webview/locales/zh-tw.ts index 2aec86b..218e095 100644 --- a/packages/platforms/vscode/webview/locales/zh-tw.ts +++ b/packages/platforms/vscode/webview/locales/zh-tw.ts @@ -1,6 +1,6 @@ -import type { en } from "./en"; +import type { LocaleSchema } from "./en"; -export const zhTw: typeof en = { +export const zhTw: LocaleSchema = { // ChatHeader "header.sessions": "工作階段列表", "header.title.fallback": "OpenCode", @@ -38,6 +38,7 @@ export const zhTw: typeof en = { "checkpoint.revertTitle": "回退到此處", "checkpoint.retryFromHere": "從此處重試", "checkpoint.forkFromHere": "從此處分支", + "scrollToBottom.ariaLabel": "捲動到底部", // Undo/Redo "header.undo": "復原",