diff --git a/src/chat-view-provider.ts b/src/chat-view-provider.ts index 247039f..ac3e9ca 100644 --- a/src/chat-view-provider.ts +++ b/src/chat-view-provider.ts @@ -86,6 +86,8 @@ export type WebviewToExtMessage = | { type: "getAgents" } | { type: "shareSession"; sessionId: string } | { type: "unshareSession"; sessionId: string } + | { type: "undoSession"; sessionId: string; messageId: string } + | { type: "redoSession"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" }; @@ -387,6 +389,22 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: "activeSession", session }); break; } + case "undoSession": { + const session = await this.connection.revertSession(message.sessionId, message.messageId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + const messages = await this.connection.getMessages(message.sessionId); + this.postMessage({ type: "messages", sessionId: message.sessionId, messages }); + break; + } + case "redoSession": { + const session = await this.connection.unrevertSession(message.sessionId); + this.activeSession = session; + this.postMessage({ type: "activeSession", session }); + const messages = await this.connection.getMessages(message.sessionId); + this.postMessage({ type: "messages", sessionId: message.sessionId, messages }); + break; + } case "openDiffEditor": { // 仮想ドキュメントを使って VS Code のネイティブ diff エディタを開く const beforeUri = vscode.Uri.parse( diff --git a/src/opencode-client.ts b/src/opencode-client.ts index 11365a7..5210cf2 100644 --- a/src/opencode-client.ts +++ b/src/opencode-client.ts @@ -347,6 +347,16 @@ export class OpenCodeConnection { return response.data!; } + // --- Unrevert API --- + + async unrevertSession(sessionId: string): Promise { + const client = this.requireClient(); + const response = await client.session.unrevert({ + path: { id: sessionId }, + }); + return response.data!; + } + // --- Summarize API --- async summarizeSession(sessionId: string, model?: { providerID: string; modelID: string }): Promise { diff --git a/webview/App.tsx b/webview/App.tsx index 33279ec..8206b01 100644 --- a/webview/App.tsx +++ b/webview/App.tsx @@ -1,5 +1,5 @@ import type { Agent, Event, Session, Todo } from "@opencode-ai/sdk"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { EmptyState } from "./components/molecules/EmptyState"; import { FileChangesHeader } from "./components/molecules/FileChangesHeader"; import { TodoHeader } from "./components/molecules/TodoHeader"; @@ -41,6 +41,11 @@ export function App() { state: string; directory: string; } | null>(null); + // handleEvent 内で activeSession を参照するために ref で追跡する。 + // これにより handleEvent の依存配列から session.activeSession(オブジェクト参照)を除外でき、 + // session.updated イベントのたびに useEffect が再登録される問題を防ぐ。 + const activeSessionRef = useRef(session.activeSession); + activeSessionRef.current = session.activeSession; const handleOpenConfigFile = useCallback((filePath: string) => { postMessage({ type: "openConfigFile", filePath }); @@ -51,6 +56,8 @@ export function App() { }, []); // SSE event handler — dispatches to domain-specific hooks + // activeSessionRef を使うことで session.activeSession(オブジェクト参照)への依存を排除し、 + // handleEvent を安定した参照に保つ。これにより useEffect の不要な再登録を防ぐ。 const handleEvent = useCallback( (event: Event) => { session.handleSessionEvent(event); @@ -58,29 +65,25 @@ export function App() { perm.handlePermissionEvent(event); fileChanges.handleFileChangeEvent(event); + const currentSession = activeSessionRef.current; + // file.edited イベント時にセッション差分を再取得する - if (event.type === "file.edited" && session.activeSession) { - postMessage({ type: "getSessionDiff", sessionId: session.activeSession.id }); + if (event.type === "file.edited" && currentSession) { + postMessage({ type: "getSessionDiff", sessionId: currentSession.id }); } // todo.updated イベント時にアクティブセッションの Todo を更新する - if (event.type === "todo.updated" && event.properties.sessionID === session.activeSession?.id) { + if (event.type === "todo.updated" && event.properties.sessionID === currentSession?.id) { setTodos(event.properties.todos as Todo[]); } // session.created イベント時にアクティブセッションの子セッションを再取得する // (サブエージェントが起動すると子セッションが作成されるため) - if (event.type === "session.created" && session.activeSession) { - postMessage({ type: "getChildSessions", sessionId: session.activeSession.id }); + if (event.type === "session.created" && currentSession) { + postMessage({ type: "getChildSessions", sessionId: currentSession.id }); } }, - [ - session.handleSessionEvent, - session.activeSession, - msg.handleMessageEvent, - perm.handlePermissionEvent, - fileChanges.handleFileChangeEvent, - ], + [session.handleSessionEvent, msg.handleMessageEvent, perm.handlePermissionEvent, fileChanges.handleFileChangeEvent], ); // Extension Host → Webview message listener @@ -319,6 +322,59 @@ export function App() { postMessage({ type: "unshareSession", sessionId: session.activeSession.id }); }, [session.activeSession]); + // Undo: 最後のアシスタントメッセージまで巻き戻す + // 巻き戻しで消えるユーザーメッセージのテキストを入力欄に復元する。 + const handleUndo = useCallback(() => { + if (!session.activeSession) return; + const messages = msg.messages; + // 最後のアシスタントメッセージを探す + const lastAssistantIdx = [...messages].reverse().findIndex((m) => m.info.role === "assistant"); + if (lastAssistantIdx < 0) return; + const assistantIdx = messages.length - 1 - lastAssistantIdx; + const lastAssistantMsg = messages[assistantIdx]; + + // アシスタントメッセージの直後にあるユーザーメッセージのテキストを取得する + const nextUserMsg = messages[assistantIdx + 1]; + if (nextUserMsg && nextUserMsg.info.role === "user") { + const textParts = nextUserMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); + const fallback = textParts.length > 0 ? textParts : nextUserMsg.parts.filter((p) => p.type === "text"); + const text = fallback.map((p) => (p as any).text).join("") || ""; + msg.setPrefillText(text); + } else { + // ユーザーメッセージがない場合(アシスタントが最後のメッセージ)は空にしない + // revert はアシスタントメッセージ自体を消すので、その前のユーザーメッセージのテキストを復元する + const prevUserMsg = [...messages] + .slice(0, assistantIdx) + .reverse() + .find((m) => m.info.role === "user"); + if (prevUserMsg) { + const textParts = prevUserMsg.parts.filter((p) => p.type === "text" && !(p as any).synthetic); + const fallback = textParts.length > 0 ? textParts : prevUserMsg.parts.filter((p) => p.type === "text"); + const text = fallback.map((p) => (p as any).text).join("") || ""; + msg.setPrefillText(text); + } + } + + postMessage({ + type: "undoSession", + sessionId: session.activeSession.id, + messageId: lastAssistantMsg.info.id, + }); + }, [session.activeSession, msg.messages, msg.setPrefillText]); + + // Redo: Undo で取り消したメッセージを復元する + // メッセージが復元されるので入力欄のプリフィルをクリアする。 + const handleRedo = useCallback(() => { + if (!session.activeSession) return; + msg.setPrefillText(""); + postMessage({ type: "redoSession", sessionId: session.activeSession.id }); + }, [session.activeSession, msg.setPrefillText]); + + // Undo 可能判定: メッセージが 2 つ以上(ユーザー + アシスタント)あり、ビジーでない + const canUndo = msg.messages.length >= 2 && !session.sessionBusy; + // Redo 可能判定: session.revert フィールドが存在する(Undo 済みの状態) + const canRedo = !!session.activeSession?.revert && !session.sessionBusy; + // 子セッションにナビゲートする const handleNavigateToChild = useCallback( (sessionId: string) => { @@ -390,6 +446,11 @@ export function App() { onShareSession={msg.messages.length > 0 ? handleShareSession : undefined} onUnshareSession={handleUnshareSession} onNavigateToParent={isChildSession ? handleNavigateToParent : undefined} + onUndo={handleUndo} + onRedo={handleRedo} + canUndo={canUndo} + canRedo={canRedo} + isBusy={session.sessionBusy} /> {session.showSessionList && ( { onToggleSessionList: vi.fn(), onShareSession: vi.fn(), onUnshareSession: vi.fn(), + canUndo: false, + canRedo: false, + isBusy: false, }; // when rendered with an active session @@ -125,4 +128,65 @@ describe("ChatHeader", () => { expect(queryByTitle("Share session")).not.toBeInTheDocument(); }); }); + + // Undo/Redo buttons + context("Undo/Redo ボタンの場合", () => { + // renders undo button + it("Undo ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Undo")).toBeInTheDocument(); + }); + + // renders redo button + it("Redo ボタンを表示すること", () => { + const { getByTitle } = render(); + expect(getByTitle("Redo")).toBeInTheDocument(); + }); + + // disables undo when canUndo is false + it("canUndo=false のとき Undo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true); + }); + + // disables redo when canRedo is false + it("canRedo=false のとき Redo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Redo") as HTMLButtonElement).disabled).toBe(true); + }); + + // disables both when busy + it("isBusy=true のとき Undo ボタンが disabled になること", () => { + const { getByTitle } = render(); + expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true); + }); + + // calls onUndo when clicked + it("クリック時に onUndo が呼ばれること", () => { + const onUndo = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Undo")); + expect(onUndo).toHaveBeenCalledOnce(); + }); + + // calls onRedo when clicked + it("クリック時に onRedo が呼ばれること", () => { + const onRedo = vi.fn(); + const { getByTitle } = render(); + fireEvent.click(getByTitle("Redo")); + expect(onRedo).toHaveBeenCalledOnce(); + }); + + // does not render undo/redo when in child session + it("子セッション閲覧中は Undo/Redo ボタンを表示しないこと", () => { + const { queryByTitle } = render( {}} canUndo canRedo />); + expect(queryByTitle("Undo")).not.toBeInTheDocument(); + }); + + // does not render undo/redo when no active session + it("アクティブセッションがない場合は Undo/Redo ボタンを表示しないこと", () => { + const { queryByTitle } = render(); + expect(queryByTitle("Undo")).not.toBeInTheDocument(); + }); + }); }); diff --git a/webview/__tests__/scenarios/20-undo-redo.test.tsx b/webview/__tests__/scenarios/20-undo-redo.test.tsx new file mode 100644 index 0000000..ec3ffe5 --- /dev/null +++ b/webview/__tests__/scenarios/20-undo-redo.test.tsx @@ -0,0 +1,237 @@ +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"; + +/** ユーザー→アシスタントの 2 メッセージ構成をセットアップする */ +async function setupWithMessages() { + renderApp(); + const session = createSession({ id: "s1", title: "Undo Test" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart = createTextPart("Hello", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("Hi there", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg, parts: [userPart] }, + { info: assistantMsg, parts: [assistantPart] }, + ], + }); + + vi.mocked(postMessage).mockClear(); + return session; +} + +/** ユーザー→アシスタント→ユーザーの 3 メッセージ構成をセットアップする */ +async function setupWithThreeMessages() { + renderApp(); + const session = createSession({ id: "s1", title: "Undo Test" }); + 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; +} + +// Undo/Redo UI +describe("Undo/Redo", () => { + // Undo button is enabled when there are multiple messages + context("メッセージが 2 つ以上ある場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // undo button is enabled + it("Undo ボタンが有効になること", () => { + const undoBtn = screen.getByTitle("Undo") as HTMLButtonElement; + expect(undoBtn.disabled).toBe(false); + }); + }); + + // Redo button is disabled when session has no revert field + context("Undo 未実行の場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // redo button is disabled + it("Redo ボタンが disabled であること", () => { + const redoBtn = screen.getByTitle("Redo") as HTMLButtonElement; + expect(redoBtn.disabled).toBe(true); + }); + }); + + // Clicking undo sends undoSession message + context("Undo ボタンをクリックした場合", () => { + beforeEach(async () => { + await setupWithMessages(); + }); + + // sends undoSession message with last assistant message id + it("最後のアシスタントメッセージ ID で undoSession メッセージを送信すること", async () => { + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "undoSession", + sessionId: "s1", + messageId: "m2", + }); + }); + }); + + // Undo prefills the input with the user message text + context("Undo でユーザーメッセージが巻き戻される場合", () => { + // prefills the input with the user text that was above the assistant message + it("入力欄にユーザーメッセージのテキストがプリフィルされること", async () => { + await setupWithThreeMessages(); + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + expect(textarea).toHaveValue("Second question"); + }); + }); + + // Undo with only user+assistant prefills user text + context("ユーザー+アシスタントの 2 メッセージで Undo した場合", () => { + // prefills the input with the user's message text + it("入力欄にユーザーメッセージのテキストがプリフィルされること", async () => { + await setupWithMessages(); + const user = userEvent.setup(); + await user.click(screen.getByTitle("Undo")); + const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)"); + expect(textarea).toHaveValue("Hello"); + }); + }); + + // Redo button is enabled after undo (session.revert is set) + context("Undo 実行後の場合", () => { + // redo button is enabled when session has revert + it("Redo ボタンが有効になること", async () => { + renderApp(); + const session = createSession({ + id: "s1", + title: "Undo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session }); + + const redoBtn = screen.getByTitle("Redo") as HTMLButtonElement; + expect(redoBtn.disabled).toBe(false); + }); + }); + + // Clicking redo sends redoSession message + context("Redo ボタンをクリックした場合", () => { + // sends redoSession message + it("redoSession メッセージを送信すること", async () => { + renderApp(); + const session = createSession({ + id: "s1", + title: "Redo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session }); + vi.mocked(postMessage).mockClear(); + + const user = userEvent.setup(); + await user.click(screen.getByTitle("Redo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "redoSession", + sessionId: "s1", + }); + }); + }); + + // Redo clears the prefill text + context("Undo 後に Redo した場合", () => { + // sends redoSession after undo + it("undoSession の後に redoSession を送信できること", async () => { + await setupWithThreeMessages(); + const user = userEvent.setup(); + + // Undo + await user.click(screen.getByTitle("Undo")); + + // session.revert を設定して Redo ボタンを有効にする + const sessionWithRevert = createSession({ + id: "s1", + title: "Undo Test", + revert: { messageID: "m2" }, + }); + await sendExtMessage({ type: "activeSession", session: sessionWithRevert }); + vi.mocked(postMessage).mockClear(); + + // Redo + await user.click(screen.getByTitle("Redo")); + expect(postMessage).toHaveBeenCalledWith({ + type: "redoSession", + sessionId: "s1", + }); + }); + }); + + // Undo/Redo buttons disabled when session is busy + context("セッションがビジーの場合", () => { + // undo button is disabled + it("Undo ボタンが disabled であること", async () => { + renderApp(); + const session = createSession({ id: "s1", title: "Busy Test" }); + await sendExtMessage({ type: "activeSession", session }); + + const userMsg = createMessage({ id: "m1", sessionID: "s1", role: "user" }); + const userPart = createTextPart("Hello", { messageID: "m1" }); + const assistantMsg = createMessage({ id: "m2", sessionID: "s1", role: "assistant" }); + const assistantPart = createTextPart("Hi", { messageID: "m2" }); + + await sendExtMessage({ + type: "messages", + sessionId: "s1", + messages: [ + { info: userMsg, parts: [userPart] }, + { info: assistantMsg, parts: [assistantPart] }, + ], + }); + + // ビジー状態にする: session.status イベントで busy を通知 + await sendExtMessage({ + type: "event", + event: { type: "session.status", properties: { status: { type: "busy" } } } as any, + }); + + const undoBtn = screen.getByTitle("Undo") as HTMLButtonElement; + expect(undoBtn.disabled).toBe(true); + }); + }); + + // No active session → no undo/redo buttons + context("アクティブセッションがない場合", () => { + // does not show undo/redo buttons + it("Undo/Redo ボタンが表示されないこと", () => { + renderApp(); + expect(screen.queryByTitle("Undo")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webview/components/atoms/icons/icons.tsx b/webview/components/atoms/icons/icons.tsx index b9b82f0..4ff99e8 100644 --- a/webview/components/atoms/icons/icons.tsx +++ b/webview/components/atoms/icons/icons.tsx @@ -329,3 +329,23 @@ export function AgentIcon({ width = 16, height = 16, ...props }: IconProps) { ); } + +// ─── Undo / Redo ────────────────────────────────────────────────────── + +/** Codicon: discard — 反時計回り矢印(Undo) */ +export function UndoIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} + +/** Codicon: redo — 時計回り矢印(Redo) */ +export function RedoIcon({ width = 16, height = 16, ...props }: IconProps) { + return ( + + ); +} diff --git a/webview/components/organisms/ChatHeader/ChatHeader.tsx b/webview/components/organisms/ChatHeader/ChatHeader.tsx index d0d2f23..8d6cbe5 100644 --- a/webview/components/organisms/ChatHeader/ChatHeader.tsx +++ b/webview/components/organisms/ChatHeader/ChatHeader.tsx @@ -1,7 +1,7 @@ import type { Session } from "@opencode-ai/sdk"; import { useLocale } from "../../../locales"; import { IconButton } from "../../atoms/IconButton"; -import { AddIcon, BackIcon, ListIcon, ShareIcon, UnshareIcon } from "../../atoms/icons"; +import { AddIcon, BackIcon, ListIcon, RedoIcon, ShareIcon, UndoIcon, UnshareIcon } from "../../atoms/icons"; import styles from "./ChatHeader.module.css"; type Props = { @@ -11,6 +11,11 @@ type Props = { onShareSession?: () => void; onUnshareSession?: () => void; onNavigateToParent?: () => void; + onUndo?: () => void; + onRedo?: () => void; + canUndo: boolean; + canRedo: boolean; + isBusy: boolean; }; export function ChatHeader({ @@ -20,6 +25,11 @@ export function ChatHeader({ onShareSession, onUnshareSession, onNavigateToParent, + onUndo, + onRedo, + canUndo, + canRedo, + isBusy, }: Props) { const t = useLocale(); // 共有中かどうかは session.share?.url の有無で判定する @@ -37,6 +47,17 @@ export function ChatHeader({ )} {activeSession?.title || t["header.title.fallback"]}
+ {/* Undo/Redo ボタン: セッションがあり、子セッション閲覧中でない場合に表示 */} + {activeSession && !onNavigateToParent && ( + <> + + + + + + + + )} {/* 共有ボタン: セッションがあり、子セッション閲覧中でない場合に表示。 未共有時は onShareSession が渡されている場合のみ表示する (メッセージのない空セッションでは SDK がエラーを返すため)。 */} diff --git a/webview/locales/en.ts b/webview/locales/en.ts index 7a34614..7ef4800 100644 --- a/webview/locales/en.ts +++ b/webview/locales/en.ts @@ -37,6 +37,10 @@ export const en = { "checkpoint.retryFromHere": "Retry from here", "checkpoint.forkFromHere": "Fork from here", + // Undo/Redo + "header.undo": "Undo", + "header.redo": "Redo", + // PermissionView "permission.allow": "Allow", "permission.once": "Once", diff --git a/webview/locales/ja.ts b/webview/locales/ja.ts index 8d5881c..d23d1f3 100644 --- a/webview/locales/ja.ts +++ b/webview/locales/ja.ts @@ -39,6 +39,10 @@ export const ja: typeof en = { "checkpoint.retryFromHere": "ここからやり直す", "checkpoint.forkFromHere": "ここから分岐", + // Undo/Redo + "header.undo": "元に戻す", + "header.redo": "やり直し", + // PermissionView "permission.allow": "許可", "permission.once": "一度だけ", diff --git a/webview/vscode-api.ts b/webview/vscode-api.ts index c43159d..d49c9cf 100644 --- a/webview/vscode-api.ts +++ b/webview/vscode-api.ts @@ -112,6 +112,8 @@ export type WebviewToExtMessage = | { type: "getAgents" } | { type: "shareSession"; sessionId: string } | { type: "unshareSession"; sessionId: string } + | { type: "undoSession"; sessionId: string; messageId: string } + | { type: "redoSession"; sessionId: string } | { type: "openDiffEditor"; filePath: string; before: string; after: string } | { type: "ready" };