Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,24 @@ export class OpenCodeConnection {
return response.data!;
}

// --- Session Share API ---

async shareSession(sessionId: string): Promise<Session> {
const client = this.requireClient();
const response = await client.session.share({
path: { id: sessionId },
});
return response.data!;
}

async unshareSession(sessionId: string): Promise<Session> {
const client = this.requireClient();
const response = await client.session.unshare({
path: { id: sessionId },
});
return response.data!;
}

// --- Agent API ---

async getAgents(): Promise<Agent[]> {
Expand Down
14 changes: 14 additions & 0 deletions webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 && (
Expand Down
67 changes: 67 additions & 0 deletions webview/__tests__/components/organisms/ChatHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +25,12 @@ describe("ChatHeader", () => {
const { container } = render(<ChatHeader {...defaultProps} />);
expect(container.querySelectorAll("button").length).toBeGreaterThan(0);
});

// renders the share button
it("共有ボタンを表示すること", () => {
const { getByTitle } = render(<ChatHeader {...defaultProps} />);
expect(getByTitle("Share session")).toBeInTheDocument();
});
});

// when new session button is clicked
Expand Down Expand Up @@ -57,5 +65,64 @@ describe("ChatHeader", () => {
const { container } = render(<ChatHeader {...defaultProps} activeSession={null} />);
expect(container.querySelector(".title")?.textContent).toBeTruthy();
});

// does not render share button
it("共有ボタンを表示しないこと", () => {
const { queryByTitle } = render(<ChatHeader {...defaultProps} activeSession={null} />);
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(<ChatHeader {...defaultProps} onShareSession={onShareSession} />);
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(<ChatHeader {...defaultProps} activeSession={sharedSession} />);
expect(getByTitle("Unshare session")).toBeInTheDocument();
});

// calls onUnshareSession when clicked
it("クリック時に onUnshareSession が呼ばれること", () => {
const onUnshareSession = vi.fn();
const { getByTitle } = render(
<ChatHeader {...defaultProps} activeSession={sharedSession} onUnshareSession={onUnshareSession} />,
);
fireEvent.click(getByTitle("Unshare session"));
expect(onUnshareSession).toHaveBeenCalledOnce();
});
});

// when navigating child session
context("子セッション閲覧中の場合", () => {
// does not render share button
it("共有ボタンを表示しないこと", () => {
const { queryByTitle } = render(<ChatHeader {...defaultProps} onNavigateToParent={() => {}} />);
expect(queryByTitle("Share session")).not.toBeInTheDocument();
});
});

// when onShareSession is not provided (empty session)
context("onShareSession が未指定の場合(空セッション)", () => {
// does not render share button
it("共有ボタンを表示しないこと", () => {
const { queryByTitle } = render(<ChatHeader {...defaultProps} onShareSession={undefined} />);
expect(queryByTitle("Share session")).not.toBeInTheDocument();
});
});
});
121 changes: 121 additions & 0 deletions webview/__tests__/scenarios/19-session-share.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof createSession>[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();
});
});
});
20 changes: 20 additions & 0 deletions webview/components/atoms/icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg aria-hidden="true" width={width} height={height} viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M4.4 3l1.086 1.086-3.232 3.232 3.232 3.232L4.4 11.636.082 7.318 4.4 3zm7.2 0l-1.086 1.086 3.232 3.232-3.232 3.232L11.6 11.636l4.318-4.318L11.6 3zM7.053 12.697L8.271 3.07l.982.136-1.218 9.627-.982-.136z" />
</svg>
);
}

/** Codicon: link-external — unshare / shared state */
export function UnshareIcon({ width = 16, height = 16, ...props }: IconProps) {
return (
<svg aria-hidden="true" width={width} height={height} viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M1.5 1H6v1H2v12h12v-4h1v4.5a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5zM9 1h6v6l-2-2-4 4-1-1 4-4L9 1z" />
</svg>
);
}

/** Codicon: person — agent/subagent icon */
export function AgentIcon({ width = 16, height = 16, ...props }: IconProps) {
return (
Expand Down
29 changes: 27 additions & 2 deletions webview/components/organisms/ChatHeader/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.root}>
{onNavigateToParent ? (
Expand All @@ -26,6 +37,20 @@ export function ChatHeader({ activeSession, onNewSession, onToggleSessionList, o
)}
<span className={styles.title}>{activeSession?.title || t["header.title.fallback"]}</span>
<div className={styles.actions}>
{/* 共有ボタン: セッションがあり、子セッション閲覧中でない場合に表示。
未共有時は onShareSession が渡されている場合のみ表示する
(メッセージのない空セッションでは SDK がエラーを返すため)。 */}
{activeSession &&
!onNavigateToParent &&
(isShared ? (
<IconButton onClick={onUnshareSession} title={t["share.unshare"]}>
<UnshareIcon />
</IconButton>
) : onShareSession ? (
<IconButton onClick={onShareSession} title={t["share.share"]}>
<ShareIcon />
</IconButton>
) : null)}
{!onNavigateToParent && (
<IconButton onClick={onNewSession} title={t["header.newChat"]}>
<AddIcon />
Expand Down
5 changes: 5 additions & 0 deletions webview/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions webview/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "親セッションに戻る",
Expand Down
Loading