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
10 changes: 10 additions & 0 deletions src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 9 additions & 0 deletions src/opencode-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ export class OpenCodeConnection {
});
}

async forkSession(sessionId: string, messageId?: string): Promise<Session> {
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<Array<{ info: Message; parts: Part[] }>> {
Expand Down
15 changes: 15 additions & 0 deletions webview/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -304,6 +317,7 @@ export function App() {
isCompressing: !!session.activeSession?.time?.compacting,
onEditAndResend: handleEditAndResend,
onRevertToCheckpoint: handleRevertToCheckpoint,
onForkFromCheckpoint: handleForkFromCheckpoint,
openCodePaths,
onOpenConfigFile: handleOpenConfigFile,
onOpenTerminal: handleOpenTerminal,
Expand Down Expand Up @@ -338,6 +352,7 @@ export function App() {
permissions={perm.permissions}
onEditAndResend={handleEditAndResend}
onRevertToCheckpoint={handleRevertToCheckpoint}
onForkFromCheckpoint={handleForkFromCheckpoint}
/>
{todos.length > 0 && <TodoHeader todos={todos} />}
{fileChanges.diffs.length > 0 && (
Expand Down
53 changes: 48 additions & 5 deletions webview/__tests__/components/organisms/MessagesArea.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,6 +34,7 @@ const defaultProps = {
permissions: new Map(),
onEditAndResend: vi.fn(),
onRevertToCheckpoint: vi.fn(),
onForkFromCheckpoint: vi.fn(),
};

describe("MessagesArea", () => {
Expand Down Expand Up @@ -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(<MessagesArea {...defaultProps} messages={msgs} />, { wrapper });
expect(container.querySelector(".checkpointDivider")).toBeInTheDocument();
});

// renders fork button in checkpoint divider
it("Fork ボタンをレンダリングすること", () => {
render(<MessagesArea {...defaultProps} messages={msgs} />, { wrapper });
expect(screen.getByText("Fork from here")).toBeInTheDocument();
});

// renders retry button in checkpoint divider
it("Retry ボタンをレンダリングすること", () => {
render(<MessagesArea {...defaultProps} messages={msgs} />, { 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(<MessagesArea {...defaultProps} messages={msgs} onForkFromCheckpoint={onFork} />, { 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(<MessagesArea {...defaultProps} messages={msgs} onRevertToCheckpoint={onRevert} />, { 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(<MessagesArea {...defaultProps} messages={msgs} />, { wrapper });
expect(screen.queryByText("Fork from here")).not.toBeInTheDocument();
});
});
});
78 changes: 78 additions & 0 deletions webview/__tests__/scenarios/16-session-fork.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
9 changes: 9 additions & 0 deletions webview/components/atoms/icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg aria-hidden="true" width={width} height={height} viewBox="0 0 16 16" fill="currentColor" {...props}>
<path d="M14 4a2 2 0 1 0-2.47 1.94V7a1 1 0 0 1-1 1H5.53a1 1 0 0 1-1-1V5.94a2 2 0 1 0-1 0V7a2 2 0 0 0 2 2h1.473v1.06a2 2 0 1 0 1 0V9H9.53a2 2 0 0 0 2-2V5.94A2 2 0 0 0 14 4zM5.03 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3.5 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3.5-8a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
);
}

// ─── Input Area Actions ───────────────────────────────────────────────

/** Codicon: terminal */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
gap: 8px;
padding: 4px 0;
margin: 2px 0;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
Expand Down Expand Up @@ -61,7 +60,7 @@
border-radius: 3px;
}

.checkpointDivider:hover .checkpointButton {
.checkpointButton:hover {
color: var(--vscode-foreground);
background-color: var(--vscode-toolbar-hoverBackground);
}
48 changes: 31 additions & 17 deletions webview/components/organisms/MessagesArea/MessagesArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +14,7 @@ type Props = {
permissions: Map<string, Permission>;
onEditAndResend: (messageId: string, text: string) => void;
onRevertToCheckpoint: (assistantMessageId: string, userText: string | null) => void;
onForkFromCheckpoint: (messageId: string) => void;
};

export function MessagesArea({
Expand All @@ -23,6 +24,7 @@ export function MessagesArea({
permissions,
onEditAndResend,
onRevertToCheckpoint,
onForkFromCheckpoint,
}: Props) {
const t = useLocale();
const bottomRef = useRef<HTMLDivElement>(null);
Expand All @@ -49,26 +51,38 @@ export function MessagesArea({
onEditAndResend={onEditAndResend}
/>
{showCheckpoint && (
<div
className={styles.checkpointDivider}
onClick={() => {
// 次のユーザーメッセージのテキストを取得して入力欄に戻す
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"]}
>
<div className={styles.checkpointDivider} title={t["checkpoint.revertTitle"]}>
<div className={styles.checkpointLine} />
<button type="button" className={styles.checkpointButton}>
<button
type="button"
className={styles.checkpointButton}
onClick={() => {
// 次のユーザーメッセージのテキストを取得して入力欄に戻す
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);
}}
>
<RevertIcon />
<span>{t["checkpoint.retryFromHere"]}</span>
</button>
<button
type="button"
className={styles.checkpointButton}
onClick={() => {
// 次のユーザーメッセージの ID を渡す。fork API は指定 ID の手前までをコピーするため、
// ユーザーメッセージ ID を渡すことでアシスタント応答までが含まれる
onForkFromCheckpoint(nextMsg.info.id);
}}
>
<ForkIcon />
<span>{t["checkpoint.forkFromHere"]}</span>
</button>
<div className={styles.checkpointLine} />
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions webview/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions webview/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions webview/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const ja: typeof en = {
// MessagesArea
"checkpoint.revertTitle": "ここまで巻き戻す",
"checkpoint.retryFromHere": "ここからやり直す",
"checkpoint.forkFromHere": "ここから分岐",

// PermissionView
"permission.allow": "許可",
Expand Down
1 change: 1 addition & 0 deletions webview/vscode-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down