Skip to content
Draft
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
60 changes: 59 additions & 1 deletion apps/desktop/electron/app-store-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { homedir } from "node:os";
import { sessionKey } from "@pi-gui/pi-sdk-driver";
import type { WorktreeCatalogEntry } from "@pi-gui/catalogs";
import type { WorkspaceRef } from "@pi-gui/session-driver";
import type { CreateWorktreeInput, DesktopAppState, RemoveWorktreeInput, StartThreadInput } from "../src/desktop-state";
import type {
CreateWorktreeInput,
DesktopAppState,
ForkThreadInput,
RemoveWorktreeInput,
StartThreadInput,
} from "../src/desktop-state";
import { sendMessageToSession } from "./app-store-composer";
import type { CreateWorktreeOptions } from "./worktree-manager";
import type { AppStoreInternals } from "./app-store-internals";
Expand Down Expand Up @@ -157,6 +163,58 @@ export async function startThread(store: AppStoreInternals, input: StartThreadIn
});
}

export async function forkThread(store: AppStoreInternals, input: ForkThreadInput): Promise<DesktopAppState> {
await store.initialize();
const sourceWorkspace = store.workspaceRefFromState(input.sourceWorkspaceId);
if (!sourceWorkspace) {
return store.withError(`Unknown workspace: ${input.sourceWorkspaceId}`);
}

return store.withErrorHandling(async () => {
let targetWorkspace = sourceWorkspace;
if (input.environment === "worktree") {
const rootWorkspace = store.workspaceRefFromState(input.rootWorkspaceId) ?? sourceWorkspace;
const worktreeOptions = buildWorktreeOptions(
store,
rootWorkspace,
input.sourceWorkspaceId,
input.sourceSessionId,
);
const created = await store.worktreeManager.createWorktree(rootWorkspace, worktreeOptions);
const synced = await store.driver.syncWorkspace(created.path, created.displayName);
targetWorkspace = synced.workspace;
}

const sourceRef = { workspaceId: input.sourceWorkspaceId, sessionId: input.sourceSessionId };
const { snapshot: session, selectedText } = await store.driver.forkSession(sourceRef, {
targetWorkspace,
userMessageIndex: input.userMessageIndex,
...(input.position ? { position: input.position } : {}),
});
store.updateSessionConfig(session.ref, session.config);

// Set selection eagerly so subscription replay events read the new session ID.
store.state = {
...store.state,
selectedWorkspaceId: session.ref.workspaceId,
selectedSessionId: session.ref.sessionId,
};

// Load the branched history transcript from the driver before publishing state.
await store.reloadTranscriptFromDriver(session.ref);

return store.refreshState({
selectedWorkspaceId: session.ref.workspaceId,
selectedSessionId: session.ref.sessionId,
composerDraft: selectedText ?? "",
composerDraftSyncSource: "selection",
clearLastError: true,
refreshWorktrees: input.environment === "worktree",
activeView: "threads",
});
});
}

export async function syncAndListWorktrees(
store: AppStoreInternals,
workspaces: readonly {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/electron/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
type CreateSessionInput,
type CreateWorktreeInput,
type DesktopAppState,
type ForkThreadInput,
type NotificationPreferences,
type QueuedComposerMessage,
type RemoveWorktreeInput,
Expand Down Expand Up @@ -335,6 +336,10 @@ export class DesktopAppStore implements AppStoreInternals {
return worktree.createWorktree(this, input);
}

async forkThread(input: ForkThreadInput): Promise<DesktopAppState> {
return worktree.forkThread(this, input);
}

async removeWorktree(input: RemoveWorktreeInput): Promise<DesktopAppState> {
return worktree.removeWorktree(this, input);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ComposerImageAttachment,
CreateSessionInput,
CreateWorktreeInput,
ForkThreadInput,
RemoveWorktreeInput,
StartThreadInput,
WorkspaceSessionTarget,
Expand Down Expand Up @@ -631,6 +632,7 @@ app.whenReady().then(async () => {
store.createSession(input),
);
ipcMain.handle(desktopIpc.startThread, (_event, input: StartThreadInput) => store.startThread(input));
ipcMain.handle(desktopIpc.forkThread, (_event, input: ForkThreadInput) => store.forkThread(input));
ipcMain.handle(desktopIpc.openSkillInFinder, async (_event, workspaceId: string, filePath: string) => {
const resolved = store.getSkillFilePath(workspaceId, filePath);
if (!resolved) {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
CreateSessionInput,
CreateWorktreeInput,
DesktopAppState,
ForkThreadInput,
NotificationPreferences,
RemoveWorktreeInput,
SelectedTranscriptRecord,
Expand Down Expand Up @@ -145,6 +146,8 @@ contextBridge.exposeInMainWorld("piApp", {
ipcRenderer.invoke(desktopIpc.createSession, input) as Promise<DesktopAppState>,
startThread: (input: StartThreadInput) =>
ipcRenderer.invoke(desktopIpc.startThread, input) as Promise<DesktopAppState>,
forkThread: (input: ForkThreadInput) =>
ipcRenderer.invoke(desktopIpc.forkThread, input) as Promise<DesktopAppState>,
cancelCurrentRun: () => ipcRenderer.invoke(desktopIpc.cancelCurrentRun) as Promise<DesktopAppState>,
setActiveView: (view: AppView) =>
ipcRenderer.invoke(desktopIpc.setActiveView, view) as Promise<DesktopAppState>,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./tests",
// Demo specs record marketing videos on demand; keep them out of default/CI discovery.
testIgnore: "**/demo/**",
timeout: 60_000,
// Electron user-surface tests are materially more reliable when one app owns the input loop at a time.
workers: 1,
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ComposerAttachment,
type ComposerImageAttachment,
type DesktopAppState,
type ForkThreadInput,
type NewThreadEnvironment,
type SelectedTranscriptRecord,
type StartThreadInput,
Expand Down Expand Up @@ -44,6 +45,7 @@ import { useThreadSearch } from "./hooks/use-thread-search";
import { useWorkspaceMenu } from "./hooks/use-workspace-menu";
import { buildExtensionDockModel, ExtensionDialog, hasExtensionDockContent } from "./extension-session-ui";
import { TreeModal } from "./tree-modal";
import { ForkModal } from "./fork-modal";
import { getEffectiveModelRuntime } from "./model-settings";
import { resolveRepoWorkspaceId } from "./workspace-roots";
import {
Expand Down Expand Up @@ -181,6 +183,17 @@ export default function App() {
loading: false,
submitting: false,
});
const [forkModalState, setForkModalState] = useState<{
readonly open: boolean;
readonly submitting: boolean;
readonly userMessageIndex: number;
readonly messagePreview?: string;
readonly error?: string;
}>({
open: false,
submitting: false,
userMessageIndex: 0,
});
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const newThreadComposerRef = useRef<HTMLTextAreaElement | null>(null);
const timelinePaneRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -756,6 +769,73 @@ export default function App() {
[api, selectedSession, selectedWorkspace],
);

const closeForkModal = useCallback(() => {
setForkModalState((current) =>
current.submitting
? current
: {
open: false,
submitting: false,
userMessageIndex: 0,
},
);
}, []);

const openForkModal = useCallback(
(turnIndex: number, preview?: string) => {
if (!api || !selectedWorkspace || !selectedSession) {
return;
}
const trimmed = preview?.trim();
setForkModalState({
open: true,
submitting: false,
userMessageIndex: turnIndex,
messagePreview: trimmed ? trimmed.slice(0, 280) : undefined,
});
},
[api, selectedSession, selectedWorkspace],
);

const handleForkSubmit = useCallback(
(environment: NewThreadEnvironment) => {
if (!api || !selectedWorkspace || !selectedSession) {
return;
}
const rootWorkspaceId =
(snapshot ? resolveRepoWorkspaceId(snapshot.workspaces, selectedWorkspace.id) : undefined) ??
selectedWorkspace.id;
const input: ForkThreadInput = {
sourceWorkspaceId: selectedWorkspace.id,
sourceSessionId: selectedSession.id,
rootWorkspaceId,
environment,
userMessageIndex: forkModalState.userMessageIndex,
position: "after",
};
setForkModalState((current) => ({ ...current, submitting: true, error: undefined }));
void api
.forkThread(input)
.then((state) => {
setSnapshot(state);
setForkModalState({
open: false,
submitting: false,
userMessageIndex: 0,
});
focusComposer();
})
.catch((error) => {
setForkModalState((current) => ({
...current,
submitting: false,
error: error instanceof Error ? error.message : String(error),
}));
});
},
[api, forkModalState.userMessageIndex, selectedSession, selectedWorkspace, snapshot],
);

const slashMenu = useSlashMenu({
composerDraft,
setComposerDraft,
Expand Down Expand Up @@ -2142,6 +2222,7 @@ export default function App() {
onJumpToLatest={jumpToLatest}
onContentHeightChange={handleTimelineContentHeightChange}
onViewFileInDiff={handleViewFileInDiff}
onForkFromMessage={openForkModal}
/>
</div>
</section>
Expand Down Expand Up @@ -2213,6 +2294,16 @@ export default function App() {
onNavigate={navigateTreeSelection}
/>
) : null}
{forkModalState.open ? (
<ForkModal
error={forkModalState.error}
submitting={forkModalState.submitting}
messagePreview={forkModalState.messagePreview}
canUseWorktree={Boolean(rootWorkspace)}
onClose={closeForkModal}
onSubmit={handleForkSubmit}
/>
) : null}
</>
) : selectedWorkspace ? (
<section className="canvas canvas--empty">
Expand Down
39 changes: 38 additions & 1 deletion apps/desktop/src/conversation-timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useRef, useState, type MutableRefObject, type RefCallback, type RefObject } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type RefCallback, type RefObject } from "react";
import type { TranscriptMessage } from "./desktop-state";
import { ThreadSearchBar } from "./thread-search";
import { TimelineItem } from "./timeline-item";
Expand Down Expand Up @@ -31,6 +31,7 @@ interface ConversationTimelineProps {
readonly onJumpToLatest: () => void;
readonly onContentHeightChange: () => void;
readonly onViewFileInDiff?: (path: string) => void;
readonly onForkFromMessage?: (turnIndex: number, preview?: string) => void;
}

export function ConversationTimeline({
Expand All @@ -46,7 +47,27 @@ export function ConversationTimeline({
onJumpToLatest,
onContentHeightChange,
onViewFileInDiff,
onForkFromMessage,
}: ConversationTimelineProps) {
// Map each assistant message id to the 0-based turn index it belongs to (the ordinal
// of the user message that started the turn). The fork affordance lives on assistant
// messages (codex-style); forking branches the conversation after that turn so the new
// thread keeps the full history up to and including the assistant response.
const forkTurnIndexByAssistantId = useMemo(() => {
const map = new Map<string, number>();
let turnIndex = -1;
for (const item of transcript) {
if (item.kind !== "message") {
continue;
}
if (item.role === "user") {
turnIndex += 1;
} else if (item.role === "assistant" && turnIndex >= 0) {
map.set(item.id, turnIndex);
}
}
return map;
}, [transcript]);
// Giant prose blocks and attachment-heavy rows routinely blow past the estimator,
// so keep those transcripts on the exact DOM path instead of restoring to a fake bottom.
const hasUnreliableVirtualizedHeights = transcript.some(
Expand Down Expand Up @@ -173,6 +194,8 @@ export function ConversationTimeline({
onHeightChange={updateMeasuredHeight}
onToggleToolCall={toggleToolCall}
onViewFileInDiff={onViewFileInDiff}
forkTurnIndexByAssistantId={forkTurnIndexByAssistantId}
onForkFromMessage={onForkFromMessage}
/>
) : (
<div className="timeline" data-testid="transcript">
Expand All @@ -184,6 +207,8 @@ export function ConversationTimeline({
expandedToolCallIds={expandedToolCallIds}
onToggleToolCall={toggleToolCall}
onViewFileInDiff={onViewFileInDiff}
forkTurnIndex={forkTurnIndexByAssistantId.get(item.id)}
onForkFromMessage={onForkFromMessage}
/>
))}
</div>
Expand All @@ -207,6 +232,8 @@ function VirtualizedTranscriptList({
onHeightChange,
onToggleToolCall,
onViewFileInDiff,
forkTurnIndexByAssistantId,
onForkFromMessage,
}: {
readonly transcript: readonly TranscriptMessage[];
readonly timelinePaneRef: MutableRefObject<HTMLDivElement | null>;
Expand All @@ -217,6 +244,8 @@ function VirtualizedTranscriptList({
readonly onHeightChange: (id: string, height: number) => void;
readonly onToggleToolCall: (callId: string) => void;
readonly onViewFileInDiff?: (path: string) => void;
readonly forkTurnIndexByAssistantId: ReadonlyMap<string, number>;
readonly onForkFromMessage?: (turnIndex: number, preview?: string) => void;
}) {
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 });
const previousTotalHeightRef = useRef(0);
Expand Down Expand Up @@ -289,6 +318,8 @@ function VirtualizedTranscriptList({
expandedToolCallIds={expandedToolCallIds}
onToggleToolCall={onToggleToolCall}
onViewFileInDiff={onViewFileInDiff}
forkTurnIndex={forkTurnIndexByAssistantId.get(item.id)}
onForkFromMessage={onForkFromMessage}
/>
);
})}
Expand All @@ -304,6 +335,8 @@ function MeasuredTimelineItem({
expandedToolCallIds,
onToggleToolCall,
onViewFileInDiff,
forkTurnIndex,
onForkFromMessage,
}: {
readonly item: TranscriptMessage;
readonly className?: string;
Expand All @@ -312,6 +345,8 @@ function MeasuredTimelineItem({
readonly expandedToolCallIds: ReadonlySet<string>;
readonly onToggleToolCall: (callId: string) => void;
readonly onViewFileInDiff?: (path: string) => void;
readonly forkTurnIndex?: number;
readonly onForkFromMessage?: (turnIndex: number, preview?: string) => void;
}) {
const rowRef = useRef<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -347,6 +382,8 @@ function MeasuredTimelineItem({
expandedToolCallIds={expandedToolCallIds}
onToggleToolCall={onToggleToolCall}
onViewFileInDiff={onViewFileInDiff}
forkTurnIndex={forkTurnIndex}
onForkFromMessage={onForkFromMessage}
/>
</div>
);
Expand Down
Loading