diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 3b1fad4..8048696 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -75,6 +75,7 @@ export type WebviewToExtMessage = | { type: "openConfigFile"; filePath: string } | { type: "openTerminal" } | { type: "setModel"; model: string } + | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } @@ -333,6 +334,15 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "modelUpdated", model: message.model, default: {} }); break; } + case "forkSession": { + // Fork で新しいセッションを作成し、アクティブセッションを切り替える + const forkedSession = await this.connection.forkSession(message.sessionId, message.messageId); + this.activeSession = forkedSession; + this.postMessage({ type: "activeSession", session: forkedSession }); + const forkedSessions = await this.connection.listSessions(); + this.postMessage({ type: "sessions", sessions: forkedSessions }); + break; + } case "getSessionDiff": { const diffs = await this.connection.getSessionDiff(message.sessionId); this.postMessage({ type: "sessionDiff", sessionId: message.sessionId, diffs }); diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 503b433..291a5e4 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -152,6 +152,15 @@ export class OpenCodeConnection { }); } + async forkSession(sessionId: string, messageId?: string): Promise { + const client = this.requireClient(); + const response = await client.session.fork({ + path: { id: sessionId }, + body: { messageID: messageId }, + }); + return response.data!; + } + // --- Message API --- async getMessages(sessionId: string): Promise> { diff --git a/webview/App.tsx b/webview/App.tsx index ae91edd..d72bd56 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -272,6 +272,19 @@ export function App() { postMessage({ type: "openDiffEditor", filePath, before, after }); }, []); + // チェックポイントからセッションを Fork する + const handleForkFromCheckpoint = useCallback( + (messageId: string) => { + if (!session.activeSession) return; + postMessage({ + type: "forkSession", + sessionId: session.activeSession.id, + messageId, + }); + }, + [session.activeSession], + ); + const contextValue: AppContextValue = { sessions: session.sessions, activeSession: session.activeSession, @@ -304,6 +317,7 @@ export function App() { isCompressing: !!session.activeSession?.time?.compacting, onEditAndResend: handleEditAndResend, onRevertToCheckpoint: handleRevertToCheckpoint, + onForkFromCheckpoint: handleForkFromCheckpoint, openCodePaths, onOpenConfigFile: handleOpenConfigFile, onOpenTerminal: handleOpenTerminal, @@ -338,6 +352,7 @@ export function App() { permissions={perm.permissions} onEditAndResend={handleEditAndResend} onRevertToCheckpoint={handleRevertToCheckpoint} + onForkFromCheckpoint={handleForkFromCheckpoint} /> {todos.length > 0 && } {fileChanges.diffs.length > 0 && ( diff --git a/webview/__tests__/components/organisms/MessagesArea.test.tsx b/webview/__tests__/components/organisms/MessagesArea.test.tsx index 01b840e..b5a3c2a 100644 --- a/webview/__tests__/components/organisms/MessagesArea.test.tsx +++ b/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -1,4 +1,5 @@ -import { render } from "@testing-library/react"; +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 type { MessageWithParts } from "../../../App"; @@ -33,6 +34,7 @@ const defaultProps = { permissions: new Map(), onEditAndResend: vi.fn(), onRevertToCheckpoint: vi.fn(), + onForkFromCheckpoint: vi.fn(), }; describe("MessagesArea", () => { @@ -67,14 +69,55 @@ describe("MessagesArea", () => { // when messages have checkpoint dividers context("アシスタント→ユーザーの連続メッセージの場合", () => { + const msgs: MessageWithParts[] = [ + { info: createMessage({ role: "assistant", id: "ast-1" }), parts: [createTextPart("Reply")] }, + { info: createMessage({ role: "user", id: "usr-1" }), parts: [createTextPart("Follow up")] }, + ]; + // renders checkpoint divider it("チェックポイント区切り線をレンダリングすること", () => { - const msgs: MessageWithParts[] = [ - { info: createMessage({ role: "assistant" }), parts: [createTextPart("Reply")] }, - { info: createMessage({ role: "user" }), parts: [createTextPart("Follow up")] }, - ]; const { container } = render(, { wrapper }); expect(container.querySelector(".checkpointDivider")).toBeInTheDocument(); }); + + // renders fork button in checkpoint divider + it("Fork ボタンをレンダリングすること", () => { + render(, { wrapper }); + expect(screen.getByText("Fork from here")).toBeInTheDocument(); + }); + + // renders retry button in checkpoint divider + it("Retry ボタンをレンダリングすること", () => { + render(, { wrapper }); + expect(screen.getByText("Retry from here")).toBeInTheDocument(); + }); + + // calls onForkFromCheckpoint with next user message ID when fork button is clicked + it("Fork ボタンクリック時に onForkFromCheckpoint を次のユーザーメッセージ ID で呼び出すこと", async () => { + const onFork = vi.fn(); + render(, { wrapper }); + const forkButton = screen.getByText("Fork from here").closest("button")!; + await userEvent.click(forkButton); + expect(onFork).toHaveBeenCalledWith("usr-1"); + }); + + // calls onRevertToCheckpoint when retry button is clicked + it("Retry ボタンクリック時に onRevertToCheckpoint を呼び出すこと", async () => { + const onRevert = vi.fn(); + render(, { wrapper }); + const retryButton = screen.getByText("Retry from here").closest("button")!; + await userEvent.click(retryButton); + expect(onRevert).toHaveBeenCalledWith("usr-1", "Follow up"); + }); + }); + + // when there are no checkpoint dividers (user only, assistant only) + context("チェックポイントが存在しない場合", () => { + // does not render fork button + it("Fork ボタンをレンダリングしないこと", () => { + const msgs: MessageWithParts[] = [{ info: createMessage({ role: "user" }), parts: [createTextPart("Hello")] }]; + render(, { wrapper }); + expect(screen.queryByText("Fork from here")).not.toBeInTheDocument(); + }); }); }); diff --git a/webview/__tests__/scenarios/16-session-fork.test.tsx b/webview/__tests__/scenarios/16-session-fork.test.tsx new file mode 100644 index 0000000..4ab6a57 --- /dev/null +++ b/webview/__tests__/scenarios/16-session-fork.test.tsx @@ -0,0 +1,78 @@ +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"; + +/** ユーザー→アシスタント→ユーザーの 3 メッセージ構成をセットアップする */ +async function setupConversation() { + renderApp(); + const session = createSession({ id: "s1", title: "Chat" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg1 = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart1 = createTextPart("First question", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("First answer", { messageID: "m2" }); + const userMsg2 = createMessage({ id: "m3", sessionID: "s1", role: "user" }); + const userPart2 = createTextPart("Second question", { messageID: "m3" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg1, parts: [userPart1] }, + { info: assistantMsg, parts: [assistantPart] }, + { info: userMsg2, parts: [userPart2] }, + ], + }); + + vi.mocked(postMessage).mockClear(); + return session; +} + +// Session fork from checkpoint +describe("セッション Fork", () => { + beforeEach(async () => { + await setupConversation(); + }); + + // Fork button is displayed on checkpoint divider + context("チェックポイント区切り線の場合", () => { + // shows fork button + it("Fork ボタンが表示されること", () => { + expect(screen.getByText("Fork from here")).toBeInTheDocument(); + }); + + // shows retry button alongside fork button + it("Retry ボタンも表示されること", () => { + expect(screen.getByText("Retry from here")).toBeInTheDocument(); + }); + }); + + // Clicking fork button sends forkSession message + context("Fork ボタンをクリックした場合", () => { + // sends forkSession message with sessionId and messageId + it("forkSession メッセージを sessionId と messageId 付きで送信すること", async () => { + const user = userEvent.setup(); + const forkButton = screen.getByText("Fork from here").closest("button")!; + await user.click(forkButton); + expect(postMessage).toHaveBeenCalledWith({ + type: "forkSession", + sessionId: "s1", + messageId: "m3", + }); + }); + }); + + // After fork, new session becomes active + context("Fork 後に activeSession メッセージを受信した場合", () => { + // switches to the forked session + it("フォークされたセッションに切り替わること", async () => { + const forkedSession = createSession({ id: "s-forked", title: "Forked Chat" }); + await sendExtMessage({ type: "activeSession", session: forkedSession }); + expect(screen.getByText("Forked Chat")).toBeInTheDocument(); + }); + }); +}); diff --git a/webview/components/atoms/icons/icons.tsx b/webview/components/atoms/icons/icons.tsx index c842e39..e5f8107 100644 --- a/webview/components/atoms/icons/icons.tsx +++ b/webview/components/atoms/icons/icons.tsx @@ -118,6 +118,15 @@ export function RevertIcon({ width = 12, height = 12, ...props }: IconProps) { ); } +/** Codicon: repo-forked — セッション Fork 用の分岐アイコン */ +export function ForkIcon({ width = 12, height = 12, ...props }: IconProps) { + return ( + + ); +} + // ─── Input Area Actions ─────────────────────────────────────────────── /** Codicon: terminal */ diff --git a/webview/components/organisms/MessagesArea/MessagesArea.module.css b/webview/components/organisms/MessagesArea/MessagesArea.module.css index d05b332..2c6a414 100644 --- a/webview/components/organisms/MessagesArea/MessagesArea.module.css +++ b/webview/components/organisms/MessagesArea/MessagesArea.module.css @@ -23,7 +23,6 @@ gap: 8px; padding: 4px 0; margin: 2px 0; - cursor: pointer; opacity: 0; transition: opacity 0.2s; } @@ -61,7 +60,7 @@ border-radius: 3px; } -.checkpointDivider:hover .checkpointButton { +.checkpointButton:hover { color: var(--vscode-foreground); background-color: var(--vscode-toolbar-hoverBackground); } diff --git a/webview/components/organisms/MessagesArea/MessagesArea.tsx b/webview/components/organisms/MessagesArea/MessagesArea.tsx index 1ca8c62..aa90e9c 100644 --- a/webview/components/organisms/MessagesArea/MessagesArea.tsx +++ b/webview/components/organisms/MessagesArea/MessagesArea.tsx @@ -2,7 +2,7 @@ import type { Permission } from "@opencode-ai/sdk"; import { useEffect, useRef } from "react"; import type { MessageWithParts } from "../../../App"; import { useLocale } from "../../../locales"; -import { RevertIcon } from "../../atoms/icons"; +import { ForkIcon, RevertIcon } from "../../atoms/icons"; import { StreamingIndicator } from "../../atoms/StreamingIndicator"; import { MessageItem } from "../MessageItem"; import styles from "./MessagesArea.module.css"; @@ -14,6 +14,7 @@ type Props = { permissions: Map; onEditAndResend: (messageId: string, text: string) => void; onRevertToCheckpoint: (assistantMessageId: string, userText: string | null) => void; + onForkFromCheckpoint: (messageId: string) => void; }; export function MessagesArea({ @@ -23,6 +24,7 @@ export function MessagesArea({ permissions, onEditAndResend, onRevertToCheckpoint, + onForkFromCheckpoint, }: Props) { const t = useLocale(); const bottomRef = useRef(null); @@ -49,26 +51,38 @@ export function MessagesArea({ onEditAndResend={onEditAndResend} /> {showCheckpoint && ( -
{ - // 次のユーザーメッセージのテキストを取得して入力欄に戻す - const userMsg = nextMsg; - const textParts = userMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); - const fallbackParts = - textParts.length > 0 ? textParts : userMsg.parts.filter((p) => p.type === "text"); - const userText = fallbackParts.map((p) => (p as any).text).join("") || null; - // revert API は指定 ID 以降を削除するので、user メッセージの ID を渡す - // こうすることで assistant メッセージまでは残る - onRevertToCheckpoint(userMsg.info.id, userText); - }} - title={t["checkpoint.revertTitle"]} - > +
- +
)} diff --git a/webview/contexts/AppContext.tsx b/webview/contexts/AppContext.tsx index a1ba74e..74806b5 100644 --- a/webview/contexts/AppContext.tsx +++ b/webview/contexts/AppContext.tsx @@ -49,6 +49,7 @@ export type AppContextValue = { isCompressing: boolean; onEditAndResend: (messageId: string, text: string) => void; onRevertToCheckpoint: (assistantMessageId: string, userText: string | null) => void; + onForkFromCheckpoint: (messageId: string) => void; // Settings openCodePaths: { home: string; config: string; state: string; directory: string } | null; diff --git a/webview/locales/en.ts b/webview/locales/en.ts index 54bcb7e..2daa807 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -35,6 +35,7 @@ export const en = { // MessagesArea "checkpoint.revertTitle": "Revert to this point", "checkpoint.retryFromHere": "Retry from here", + "checkpoint.forkFromHere": "Fork from here", // PermissionView "permission.allow": "Allow", diff --git a/webview/locales/ja.ts b/webview/locales/ja.ts index 274da8e..0a453ba 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -37,6 +37,7 @@ export const ja: typeof en = { // MessagesArea "checkpoint.revertTitle": "ここまで巻き戻す", "checkpoint.retryFromHere": "ここからやり直す", + "checkpoint.forkFromHere": "ここから分岐", // PermissionView "permission.allow": "許可", diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index 8dd1b84..a07f4ff 100644 --- a/webview/vscode-api.ts +++ b/webview/vscode-api.ts @@ -102,6 +102,7 @@ export type WebviewToExtMessage = | { type: "openConfigFile"; filePath: string } | { type: "openTerminal" } | { type: "setModel"; model: string } + | { type: "forkSession"; sessionId: string; messageId?: string } | { type: "getSessionDiff"; sessionId: string } | { type: "getSessionTodos"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string }