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 @@ -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" };

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

// --- Unrevert API ---

async unrevertSession(sessionId: string): Promise<Session> {
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<void> {
Expand Down
87 changes: 74 additions & 13 deletions webview/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
Expand All @@ -51,36 +56,34 @@ export function App() {
}, []);

// SSE event handler — dispatches to domain-specific hooks
// activeSessionRef を使うことで session.activeSession(オブジェクト参照)への依存を排除し、
// handleEvent を安定した参照に保つ。これにより useEffect の不要な再登録を防ぐ。
const handleEvent = useCallback(
(event: Event) => {
session.handleSessionEvent(event);
msg.handleMessageEvent(event);
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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 && (
<SessionList
Expand Down
64 changes: 64 additions & 0 deletions webview/__tests__/components/organisms/ChatHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ describe("ChatHeader", () => {
onToggleSessionList: vi.fn(),
onShareSession: vi.fn(),
onUnshareSession: vi.fn(),
canUndo: false,
canRedo: false,
isBusy: false,
};

// when rendered with an active session
Expand Down Expand Up @@ -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(<ChatHeader {...defaultProps} canUndo canRedo={false} />);
expect(getByTitle("Undo")).toBeInTheDocument();
});

// renders redo button
it("Redo ボタンを表示すること", () => {
const { getByTitle } = render(<ChatHeader {...defaultProps} canUndo={false} canRedo />);
expect(getByTitle("Redo")).toBeInTheDocument();
});

// disables undo when canUndo is false
it("canUndo=false のとき Undo ボタンが disabled になること", () => {
const { getByTitle } = render(<ChatHeader {...defaultProps} canUndo={false} />);
expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true);
});

// disables redo when canRedo is false
it("canRedo=false のとき Redo ボタンが disabled になること", () => {
const { getByTitle } = render(<ChatHeader {...defaultProps} canRedo={false} />);
expect((getByTitle("Redo") as HTMLButtonElement).disabled).toBe(true);
});

// disables both when busy
it("isBusy=true のとき Undo ボタンが disabled になること", () => {
const { getByTitle } = render(<ChatHeader {...defaultProps} canUndo canRedo isBusy />);
expect((getByTitle("Undo") as HTMLButtonElement).disabled).toBe(true);
});

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

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

// does not render undo/redo when in child session
it("子セッション閲覧中は Undo/Redo ボタンを表示しないこと", () => {
const { queryByTitle } = render(<ChatHeader {...defaultProps} onNavigateToParent={() => {}} canUndo canRedo />);
expect(queryByTitle("Undo")).not.toBeInTheDocument();
});

// does not render undo/redo when no active session
it("アクティブセッションがない場合は Undo/Redo ボタンを表示しないこと", () => {
const { queryByTitle } = render(<ChatHeader {...defaultProps} activeSession={null} canUndo canRedo />);
expect(queryByTitle("Undo")).not.toBeInTheDocument();
});
});
});
Loading