diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index b07d537..247039f 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -84,6 +84,8 @@ export type WebviewToExtMessage = | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } | { type: "getAgents" } + | { type: "shareSession"; sessionId: string } + | { type: "unshareSession"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; @@ -369,6 +371,22 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "agents", agents }); break; } + case "shareSession": { + const session = await this.connection.shareSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + // 共有 URL をクリップボードにコピーする + if (session.share?.url) { + await vscode.env.clipboard.writeText(session.share.url); + } + break; + } + case "unshareSession": { + const session = await this.connection.unshareSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + break; + } case "openDiffEditor": { // 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く const beforeUri = vscode.Uri.parse( diff --git a/src/opencode-client.ts b/src/opencode-client.ts index a165b15..11365a7 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -300,6 +300,24 @@ export class OpenCodeConnection { return response.data!; } + // --- Session Share API --- + + async shareSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.share({ + path: { id: sessionId }, + }); + return response.data!; + } + + async unshareSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.unshare({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- Agent API --- async getAgents(): Promise { diff --git a/webview/App.tsx b/webview/App.tsx index 4d1de8e..33279ec 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -307,6 +307,18 @@ export function App() { [session.activeSession], ); + // セッションを共有する + const handleShareSession = useCallback(() => { + if (!session.activeSession) return; + postMessage({ type: "shareSession", sessionId: session.activeSession.id }); + }, [session.activeSession]); + + // セッションの共有を解除する + const handleUnshareSession = useCallback(() => { + if (!session.activeSession) return; + postMessage({ type: "unshareSession", sessionId: session.activeSession.id }); + }, [session.activeSession]); + // 子セッションにナビゲートする const handleNavigateToChild = useCallback( (sessionId: string) => { @@ -375,6 +387,8 @@ export function App() { activeSession={session.activeSession} onNewSession={session.handleNewSession} onToggleSessionList={session.toggleSessionList} + onShareSession={msg.messages.length > 0 ? handleShareSession : undefined} + onUnshareSession={handleUnshareSession} onNavigateToParent={isChildSession ? handleNavigateToParent : undefined} /> {session.showSessionList && ( diff --git a/webview/__tests__/components/organisms/ChatHeader.test.tsx b/webview/__tests__/components/organisms/ChatHeader.test.tsx index f9ac58e..533212b 100644 --- a/webview/__tests__/components/organisms/ChatHeader.test.tsx +++ b/webview/__tests__/components/organisms/ChatHeader.test.tsx @@ -8,6 +8,8 @@ describe("ChatHeader", () => { activeSession: createSession({ title: "Test Session" }), onNewSession: vi.fn(), onToggleSessionList: vi.fn(), + onShareSession: vi.fn(), + onUnshareSession: vi.fn(), }; // when rendered with an active session @@ -23,6 +25,12 @@ describe("ChatHeader", () => { const { container } = render(); expect(container.querySelectorAll("button").length).toBeGreaterThan(0); }); + + // renders the share button + it("共有ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Share session")).toBeInTheDocument(); + }); }); // when new session button is clicked @@ -57,5 +65,64 @@ describe("ChatHeader", () => { const { container } = render(); expect(container.querySelector(".title")?.textContent).toBeTruthy(); }); + + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when share button is clicked (not shared) + context("未共有セッションで共有ボタンをクリックした場合", () => { + // calls onShareSession + it("onShareSession が呼ばれること", () => { + const onShareSession = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Share session")); + expect(onShareSession).toHaveBeenCalledOnce(); + }); + }); + + // when session is shared + context("共有中のセッションの場合", () => { + const sharedSession = createSession({ + title: "Shared Session", + share: { url: "https://share.example.com/abc" }, + }); + + // renders unshare button + it("共有解除ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Unshare session")).toBeInTheDocument(); + }); + + // calls onUnshareSession when clicked + it("クリック時に onUnshareSession が呼ばれること", () => { + const onUnshareSession = vi.fn(); + const { getByTitle } = render( + , + ); + fireEvent.click(getByTitle("Unshare session")); + expect(onUnshareSession).toHaveBeenCalledOnce(); + }); + }); + + // when navigating child session + context("子セッション閲覧中の場合", () => { + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render( {}} />); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when onShareSession is not provided (empty session) + context("onShareSession が未指定の場合(空セッション)", () => { + // does not render share button + it("共有ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Share session")).not.toBeInTheDocument(); + }); }); }); diff --git a/webview/__tests__/scenarios/19-session-share.test.tsx b/webview/__tests__/scenarios/19-session-share.test.tsx new file mode 100644 index 0000000..652d2e7 --- /dev/null +++ b/webview/__tests__/scenarios/19-session-share.test.tsx @@ -0,0 +1,121 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { postMessage } from "../../vscode-api"; +import { createMessage, createSession, createTextPart } from "../factories"; +import { renderApp, sendExtMessage } from "../helpers"; + +/** + * メッセージが存在するセッションをセットアップするヘルパー。 + * 空セッションでは共有ボタンが表示されないため、必ずメッセージを送信する。 + */ +async function setupSessionWithMessages(sessionOverrides: Partial[0]> = {}) { + const session = createSession({ id: "s1", title: "Chat", ...sessionOverrides }); + await sendExtMessage({ type: "activeSession", session }); + const msg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const part = createTextPart("hello", { messageID: "m1" }); + await sendExtMessage({ type: "messages", sessionId: "s1", messages: [{ info: msg, parts: [part] }] }); + return session; +} + +// Session share +describe("セッション共有", () => { + // when session is active (not shared) + context("未共有のアクティブセッションがある場合", () => { + beforeEach(async () => { + renderApp(); + await setupSessionWithMessages(); + vi.mocked(postMessage).mockClear(); + }); + + // shows share button + it("共有ボタンが表示されること", () => { + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + }); + + // sends shareSession message when clicked + it("共有ボタンクリック時に shareSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Share session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "shareSession", + sessionId: "s1", + }); + }); + }); + + // when session becomes shared + context("セッションが共有状態になった場合", () => { + beforeEach(async () => { + renderApp(); + await setupSessionWithMessages({ share: { url: "https://share.example.com/abc" } }); + vi.mocked(postMessage).mockClear(); + }); + + // shows unshare button + it("共有解除ボタンが表示されること", () => { + expect(screen.getByTitle("Unshare session")).toBeInTheDocument(); + }); + + // share button is not visible + it("共有ボタンが表示されないこと", () => { + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + + // sends unshareSession message when clicked + it("共有解除ボタンクリック時に unshareSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Unshare session")); + expect(postMessage).toHaveBeenCalledWith({ + type: "unshareSession", + sessionId: "s1", + }); + }); + }); + + // share → unshare flow + context("共有して共有解除するフローの場合", () => { + // share state toggles correctly + it("共有状態が正しく切り替わること", async () => { + renderApp(); + + // 1. メッセージ付きの未共有セッションを設定 + await setupSessionWithMessages(); + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + + // 2. 共有状態のセッションに更新 + const sharedSession = createSession({ + id: "s1", + title: "Chat", + share: { url: "https://share.example.com/abc" }, + }); + await sendExtMessage({ type: "activeSession", session: sharedSession }); + expect(screen.getByTitle("Unshare session")).toBeInTheDocument(); + + // 3. 共有解除されたセッションに更新 + const unsharedSession = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session: unsharedSession }); + expect(screen.getByTitle("Share session")).toBeInTheDocument(); + }); + }); + + // when no active session + context("アクティブセッションがない場合", () => { + // does not show share button + it("共有ボタンが表示されないこと", () => { + renderApp(); + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); + + // when session has no messages (empty session) + context("メッセージのない空セッションの場合", () => { + // does not show share button + it("共有ボタンが表示されないこと", async () => { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + expect(screen.queryByTitle("Share session")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/components/atoms/icons/icons.tsx b/webview/components/atoms/icons/icons.tsx index 4419866..b9b82f0 100644 --- a/webview/components/atoms/icons/icons.tsx +++ b/webview/components/atoms/icons/icons.tsx @@ -301,6 +301,26 @@ export function BackIcon({ width = 16, height = 16, ...props }: IconProps) { ); } +// ─── Share ──────────────────────────────────────────────────────────── + +/** Codicon: link — share session */ +export function ShareIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: link-external — unshare / shared state */ +export function UnshareIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + /** Codicon: person — agent/subagent icon */ export function AgentIcon({ width = 16, height = 16, ...props }: IconProps) { return ( diff --git a/webview/components/organisms/ChatHeader/ChatHeader.tsx b/webview/components/organisms/ChatHeader/ChatHeader.tsx index 639a32c..d0d2f23 100644 --- a/webview/components/organisms/ChatHeader/ChatHeader.tsx +++ b/webview/components/organisms/ChatHeader/ChatHeader.tsx @@ -1,18 +1,29 @@ import type { Session } from "@opencode-ai/sdk"; import { useLocale } from "../../../locales"; import { IconButton } from "../../atoms/IconButton"; -import { AddIcon, BackIcon, ListIcon } from "../../atoms/icons"; +import { AddIcon, BackIcon, ListIcon, ShareIcon, UnshareIcon } from "../../atoms/icons"; import styles from "./ChatHeader.module.css"; type Props = { activeSession: Session | null; onNewSession: () => void; onToggleSessionList: () => void; + onShareSession?: () => void; + onUnshareSession?: () => void; onNavigateToParent?: () => void; }; -export function ChatHeader({ activeSession, onNewSession, onToggleSessionList, onNavigateToParent }: Props) { +export function ChatHeader({ + activeSession, + onNewSession, + onToggleSessionList, + onShareSession, + onUnshareSession, + onNavigateToParent, +}: Props) { const t = useLocale(); + // 共有中かどうかは session.share?.url の有無で判定する + const isShared = !!activeSession?.share?.url; return (
{onNavigateToParent ? ( @@ -26,6 +37,20 @@ export function ChatHeader({ activeSession, onNewSession, onToggleSessionList, o )} {activeSession?.title || t["header.title.fallback"]}
+ {/* 共有ボタン: セッションがあり、子セッション閲覧中でない場合に表示。 + 未共有時は onShareSession が渡されている場合のみ表示する + (メッセージのない空セッションでは SDK がエラーを返すため)。 */} + {activeSession && + !onNavigateToParent && + (isShared ? ( + + + + ) : onShareSession ? ( + + + + ) : null)} {!onNavigateToParent && ( diff --git a/webview/locales/en.ts b/webview/locales/en.ts index 5459330..7a34614 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -108,6 +108,11 @@ export const en = { "fileChanges.openDiff": "Open in diff editor", "fileChanges.toggle": "File changes", + // Share + "share.share": "Share session", + "share.unshare": "Unshare session", + "share.copied": "Share URL copied to clipboard", + // ChildSession "childSession.agent": "Agent", "childSession.backToParent": "Back to parent session", diff --git a/webview/locales/ja.ts b/webview/locales/ja.ts index b25114e..8d5881c 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -110,6 +110,11 @@ export const ja: typeof en = { "fileChanges.openDiff": "差分エディタで開く", "fileChanges.toggle": "ファイル変更", + // Share + "share.share": "セッションを共有", + "share.unshare": "共有を解除", + "share.copied": "共有 URL をクリップボードにコピーしました", + // ChildSession "childSession.agent": "エージェント", "childSession.backToParent": "親セッションに戻る", diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index 4792db1..c43159d 100644 --- a/webview/vscode-api.ts +++ b/webview/vscode-api.ts @@ -110,6 +110,8 @@ export type WebviewToExtMessage = | { type: "getSessionTodos"; sessionId: string } | { type: "getChildSessions"; sessionId: string } | { type: "getAgents" } + | { type: "shareSession"; sessionId: string } + | { type: "unshareSession"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" };