;
activeTurnId?: TurnId | null;
isRevertingCheckpoint: boolean;
onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -473,6 +478,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onRevertUserMessage,
onUndoTurnFiles,
onEditUserMessage,
+ onForkFromMessage,
+ forkableMessageIds,
activeTurnId,
isRevertingCheckpoint,
onImageExpand,
@@ -1195,6 +1202,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
className={MESSAGE_HOVER_REVEAL_CLASS_NAME}
/>
)}
+ {onForkFromMessage &&
+ (forkableMessageIds === undefined ||
+ forkableMessageIds.has(row.message.id)) ? (
+ onForkFromMessage(row.message.id)}
+ >
+
+
+ ) : null}
{showEditUserMessage && (
) : null}
+ {assistantCopyState.visible &&
+ onForkFromMessage &&
+ (forkableMessageIds === undefined || forkableMessageIds.has(row.message.id)) ? (
+ onForkFromMessage(row.message.id)}
+ >
+
+
+ ) : null}
{assistantMeta.length > 0 ? (
{assistantMeta}
diff --git a/apps/web/src/composerSlashCommands.test.ts b/apps/web/src/composerSlashCommands.test.ts
index ccde0f2e..964eef94 100644
--- a/apps/web/src/composerSlashCommands.test.ts
+++ b/apps/web/src/composerSlashCommands.test.ts
@@ -288,7 +288,7 @@ describe("composerSlashCommands", () => {
expect(shouldHideProviderNativeCommandFromComposerMenu("claudeAgent", "feedback")).toBe(true);
});
- it("only exposes Synara-owned app commands for claude", () => {
+ it("offers Scient-owned fork handling for Claude", () => {
expect(
getAvailableComposerSlashCommands({
provider: "claudeAgent",
@@ -299,7 +299,27 @@ describe("composerSlashCommands", () => {
canOfferSideCommand: true,
canOfferExportCommand: true,
}),
- ).toEqual(["side", "export", "feedback", "automation"]);
+ ).toEqual(["fork", "side", "export", "feedback", "automation"]);
+ });
+
+ it("keeps app-level /fork for Claude when native /branch advertises the fork alias", () => {
+ const commands = getAvailableComposerSlashCommands({
+ provider: "claudeAgent",
+ supportsFastSlashCommand: true,
+ canOfferCompactCommand: true,
+ canOfferReviewCommand: true,
+ canOfferForkCommand: true,
+ canOfferSideCommand: true,
+ canOfferExportCommand: true,
+ providerNativeCommandNames: ["branch"],
+ });
+
+ expect(commands).toContain("fork");
+ expect(parseComposerSlashInvocationForCommands("/fork", commands)).toEqual({
+ command: "fork",
+ args: "",
+ });
+ expect(parseComposerSlashInvocationForCommands("/branch", commands)).toBeNull();
});
it("offers the app-level /export command on every provider", () => {
diff --git a/apps/web/src/composerSlashCommands.ts b/apps/web/src/composerSlashCommands.ts
index 21b5c05d..cbac5999 100644
--- a/apps/web/src/composerSlashCommands.ts
+++ b/apps/web/src/composerSlashCommands.ts
@@ -85,6 +85,7 @@ function shouldKeepBuiltInSlashCommandDespiteNativeCollision(
command === "automation" ||
command === "export" ||
command === "feedback" ||
+ (provider === "claudeAgent" && command === "fork") ||
(providerUsesAppOwnedReviewSlashCommand(provider) && command === "review")
);
}
@@ -421,8 +422,9 @@ export function getAvailableComposerSlashCommands(input: {
"automation",
]
: [
- // Claude owns most slash-command UX natively; sidechat remains app-level because it
- // creates a Synara split/context clone before the provider sees the first turn.
+ // Claude owns most slash-command UX natively. Fork and sidechat remain app-level
+ // because they create Scient-owned tasks before the provider sees the first turn.
+ ...(input.canOfferForkCommand ? (["fork"] as const) : []),
// /export is app-level too — Synara owns the thread transcript, so the download
// happens in the app rather than being forwarded to Claude's native /export.
...(input.canOfferSideCommand ? (["side"] as const) : []),
diff --git a/apps/web/src/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts
index a4c1734d..aeeb5216 100644
--- a/apps/web/src/hooks/useComposerSlashCommands.ts
+++ b/apps/web/src/hooks/useComposerSlashCommands.ts
@@ -1,5 +1,6 @@
import {
type ModelSelection,
+ type MessageId,
type OrchestrationShellSnapshot,
type ProviderInteractionMode,
type ProviderKind,
@@ -10,7 +11,7 @@ import {
} from "@synara/contracts";
import { buildPromptThreadTitleFallback } from "@synara/shared/chatThreads";
import { deriveAssociatedWorktreeMetadata } from "@synara/shared/threadWorkspace";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { newCommandId, newMessageId, newThreadId } from "../lib/utils";
import { readNativeApi } from "../nativeApi";
import type { Project, Thread } from "../types";
@@ -26,7 +27,10 @@ import {
parseForkSlashCommandArgs,
type ForkSlashCommandTarget,
} from "../composerSlashCommands";
-import { buildThreadHandoffImportedMessages } from "../lib/threadHandoff";
+import {
+ buildThreadForkImportedMessagesThrough,
+ buildThreadHandoffImportedMessages,
+} from "../lib/threadHandoff";
import { toastManager } from "../components/ui/toast";
import type { ComposerCommandItem } from "../components/chat/ComposerCommandMenu";
import { buildNextProviderOptions } from "../providerModelOptions";
@@ -100,6 +104,7 @@ export function useComposerSlashCommands(input: {
};
}) {
const [isSlashStatusDialogOpen, setIsSlashStatusDialogOpen] = useState(false);
+ const forkCreationInFlightRef = useRef(false);
const openGlobalFeedbackDialog = useFeedbackDialogStore((state) => state.openDialog);
const {
activeProject,
@@ -243,7 +248,7 @@ export function useComposerSlashCommands(input: {
);
const createForkThreadFromSlashCommand = useCallback(
- async (inputOptions?: { target?: ForkSlashCommandTarget }) => {
+ async (inputOptions?: { target?: ForkSlashCommandTarget; sourceMessageId?: MessageId }) => {
const api = readNativeApi();
if (!api || !activeProject || !activeThread || !isServerThread) {
toastManager.add({
@@ -254,9 +259,23 @@ export function useComposerSlashCommands(input: {
return true;
}
- const importedMessages = buildThreadHandoffImportedMessages(activeThread);
-
const nextThreadId = newThreadId();
+ const importedMessages = inputOptions?.sourceMessageId
+ ? buildThreadForkImportedMessagesThrough(
+ activeThread,
+ inputOptions.sourceMessageId,
+ nextThreadId,
+ )
+ : buildThreadHandoffImportedMessages(activeThread);
+ if (inputOptions?.sourceMessageId && importedMessages.length === 0) {
+ toastManager.add({
+ type: "warning",
+ title: "Fork is unavailable",
+ description: "That message is not available as a completed conversation boundary.",
+ });
+ return true;
+ }
+
const createdAt = new Date().toISOString();
// Fork first, then let the normal first-send worktree bootstrap create the cwd if needed.
const resolvedTarget = resolveForkThreadEnvironment({
@@ -270,6 +289,7 @@ export function useComposerSlashCommands(input: {
commandId: newCommandId(),
threadId: nextThreadId,
sourceThreadId: activeThread.id,
+ ...(inputOptions?.sourceMessageId ? { sourceMessageId: inputOptions.sourceMessageId } : {}),
projectId: activeProject.id,
title: activeThread.title,
modelSelection: selectedModelSelection,
@@ -301,7 +321,6 @@ export function useComposerSlashCommands(input: {
syncServerShellSnapshot,
],
);
-
const createSidechatFromSlashCommand = useCallback(
async (inputOptions?: { initialPrompt?: string }) => {
const api = readNativeApi();
@@ -511,6 +530,8 @@ export function useComposerSlashCommands(input: {
const handleForkTargetSelection = useCallback(
async (target: ForkSlashCommandTarget) => {
+ if (forkCreationInFlightRef.current) return;
+ forkCreationInFlightRef.current = true;
try {
await createForkThreadFromSlashCommand({ target });
} catch (error) {
@@ -522,6 +543,33 @@ export function useComposerSlashCommands(input: {
? error.message
: "An error occurred while creating the forked thread.",
});
+ } finally {
+ forkCreationInFlightRef.current = false;
+ }
+ },
+ [createForkThreadFromSlashCommand],
+ );
+
+ const handleForkFromMessage = useCallback(
+ async (sourceMessageId: MessageId) => {
+ if (forkCreationInFlightRef.current) return;
+ forkCreationInFlightRef.current = true;
+ try {
+ await createForkThreadFromSlashCommand({
+ target: "local",
+ sourceMessageId,
+ });
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Could not fork from message",
+ description:
+ error instanceof Error
+ ? error.message
+ : "An error occurred while creating the message fork.",
+ });
+ } finally {
+ forkCreationInFlightRef.current = false;
}
},
[createForkThreadFromSlashCommand],
@@ -1003,6 +1051,7 @@ export function useComposerSlashCommands(input: {
);
return {
+ handleForkFromMessage,
handleForkTargetSelection,
handleReviewTargetSelection,
isSlashStatusDialogOpen,
diff --git a/apps/web/src/lib/assistantSelections.ts b/apps/web/src/lib/assistantSelections.ts
index f78d1326..acf51e93 100644
--- a/apps/web/src/lib/assistantSelections.ts
+++ b/apps/web/src/lib/assistantSelections.ts
@@ -3,14 +3,15 @@
// Layer: Chat composer and transcript helpers
import { CHAT_ASSISTANT_SELECTION_TEXT_MAX_CHARS } from "@synara/contracts";
+import { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections";
+
+export { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections";
import type { ChatAssistantSelectionAttachment } from "../types";
import { randomUUID } from "./utils";
const TRAILING_ASSISTANT_SELECTIONS_PATTERN =
/\n*\n([\s\S]*?)\n<\/assistant_selection>\s*$/;
-const EMBEDDED_ASSISTANT_SELECTIONS_PATTERN =
- /\n*\n[\s\S]*?\n<\/assistant_selection>(?=\n*(\n[\s\S]*?\n<\/terminal_context>\s*)?(\n[\s\S]*?\n<\/file_comments>\s*)?(\n[\s\S]*?\n<\/pasted_text>\s*)?$)/;
const ASSISTANT_SELECTION_PREVIEW_MAX_CHARS = 44;
export interface ExtractedAssistantSelections {
@@ -155,10 +156,6 @@ export function stripTrailingAssistantSelections(prompt: string): string {
return extractTrailingAssistantSelections(prompt).promptText;
}
-export function stripEmbeddedAssistantSelections(prompt: string): string {
- return prompt.replace(EMBEDDED_ASSISTANT_SELECTIONS_PATTERN, "");
-}
-
function parseAssistantSelectionEntries(block: string): ParsedAssistantSelectionEntry[] {
const entries: ParsedAssistantSelectionEntry[] = [];
let current: { assistantMessageId: string; lines: string[] } | null = null;
diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx
index 8746e43a..e26bcb58 100644
--- a/apps/web/src/lib/icons.tsx
+++ b/apps/web/src/lib/icons.tsx
@@ -189,6 +189,7 @@ export const FoldersIcon: LucideIcon = centralIconWrapper("folders");
export const GitCommitIcon: LucideIcon = centralIconWrapper("commits");
export const GitBranchIcon: LucideIcon = centralIconWrapper("branch");
export const GitForkIcon = centralIconWrapper("fork");
+export const ConversationForkIcon: LucideIcon = centralIconWrapper("fork-simple");
export const GitMergeIcon: LucideIcon = centralIconWrapper("merged");
export const GitMergedSimpleIcon: LucideIcon = centralIconWrapper("merged-simple");
export const PushIcon: LucideIcon = centralIconWrapper("cloud-simple-upload");
diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts
index 0475b9dc..2ac5e7b1 100644
--- a/apps/web/src/lib/threadHandoff.test.ts
+++ b/apps/web/src/lib/threadHandoff.test.ts
@@ -1,12 +1,412 @@
-import { type ModelSelection } from "@synara/contracts";
+import { MessageId, type ModelSelection, ThreadId, TurnId } from "@synara/contracts";
import { describe, expect, it } from "vitest";
import {
+ buildThreadForkImportedMessagesThrough,
+ resolveThreadForkableMessageIds,
resolveAvailableHandoffTargetProviders,
resolveThreadHandoffTitle,
resolveThreadHandoffModelSelection,
} from "./threadHandoff";
describe("threadHandoff", () => {
+ it("imports only completed conversation messages through the selected fork boundary", () => {
+ const firstUserId = MessageId.makeUnsafe("message-user-1");
+ const firstAssistantId = MessageId.makeUnsafe("message-assistant-1");
+ const secondUserId = MessageId.makeUnsafe("message-user-2");
+
+ const imported = buildThreadForkImportedMessagesThrough(
+ {
+ messages: [
+ {
+ id: firstUserId,
+ role: "user",
+ text: "First question",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: MessageId.makeUnsafe("message-system-1"),
+ role: "system",
+ text: "Internal status",
+ createdAt: "2026-07-22T08:00:01.000Z",
+ streaming: false,
+ },
+ {
+ id: firstAssistantId,
+ role: "assistant",
+ text: "First answer",
+ createdAt: "2026-07-22T08:00:02.000Z",
+ completedAt: "2026-07-22T08:00:03.000Z",
+ streaming: false,
+ },
+ {
+ id: secondUserId,
+ role: "user",
+ text: "Later question",
+ createdAt: "2026-07-22T08:01:00.000Z",
+ streaming: false,
+ },
+ ],
+ },
+ firstAssistantId,
+ ThreadId.makeUnsafe("thread-fork-destination"),
+ );
+
+ expect(imported).toHaveLength(2);
+ expect(imported.map(({ role, text }) => ({ role, text }))).toEqual([
+ { role: "user", text: "First question" },
+ { role: "assistant", text: "First answer" },
+ ]);
+ expect(imported.every((message) => message.messageId !== firstUserId)).toBe(true);
+ expect(imported.every((message) => message.messageId !== firstAssistantId)).toBe(true);
+ });
+
+ it("rejects missing or streaming fork boundaries", () => {
+ const streamingId = MessageId.makeUnsafe("message-streaming");
+ const thread = {
+ messages: [
+ {
+ id: streamingId,
+ role: "assistant" as const,
+ text: "Still running",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: true,
+ },
+ ],
+ };
+
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ streamingId,
+ ThreadId.makeUnsafe("thread-fork-streaming"),
+ ),
+ ).toEqual([]);
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ MessageId.makeUnsafe("message-missing"),
+ ThreadId.makeUnsafe("thread-fork-missing"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("fails closed for a queued user boundary after a live assistant", () => {
+ const priorUserId = MessageId.makeUnsafe("message-prior-user");
+ const liveAssistantId = MessageId.makeUnsafe("message-live-assistant");
+ const queuedUserId = MessageId.makeUnsafe("message-queued-user");
+ const liveTurnId = TurnId.makeUnsafe("turn-live");
+ const thread = {
+ messages: [
+ {
+ id: priorUserId,
+ role: "user" as const,
+ text: "Prior question",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: liveAssistantId,
+ role: "assistant" as const,
+ text: "Still answering",
+ turnId: liveTurnId,
+ createdAt: "2026-07-22T08:00:01.000Z",
+ streaming: true,
+ },
+ {
+ id: queuedUserId,
+ role: "user" as const,
+ text: "Queued follow-up",
+ createdAt: "2026-07-22T08:00:02.000Z",
+ streaming: false,
+ },
+ ],
+ latestTurn: {
+ turnId: liveTurnId,
+ state: "running" as const,
+ requestedAt: "2026-07-22T08:00:00.000Z",
+ startedAt: "2026-07-22T08:00:00.500Z",
+ completedAt: null,
+ assistantMessageId: liveAssistantId,
+ },
+ session: null,
+ };
+
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ queuedUserId,
+ ThreadId.makeUnsafe("thread-fork-queued"),
+ ),
+ ).toEqual([]);
+ expect([...resolveThreadForkableMessageIds(thread)]).toEqual([priorUserId]);
+ });
+
+ it("fails closed for queued input before the running turn projects an assistant", () => {
+ const activeUserId = MessageId.makeUnsafe("message-active-user-before-assistant");
+ const queuedUserId = MessageId.makeUnsafe("message-queued-before-assistant");
+ const turnId = TurnId.makeUnsafe("turn-before-assistant");
+ const thread = {
+ messages: [
+ {
+ id: MessageId.makeUnsafe("message-prior-settled-user"),
+ role: "user" as const,
+ text: "Settled prompt",
+ createdAt: "2026-07-22T07:59:00.000Z",
+ streaming: false,
+ },
+ {
+ id: activeUserId,
+ role: "user" as const,
+ text: "Prompt currently running",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: queuedUserId,
+ role: "user" as const,
+ text: "Queued before an assistant row exists",
+ createdAt: "2026-07-22T08:00:01.000Z",
+ streaming: false,
+ },
+ ],
+ latestTurn: {
+ turnId,
+ state: "running" as const,
+ requestedAt: "2026-07-22T08:00:00.000Z",
+ startedAt: "2026-07-22T08:00:00.500Z",
+ completedAt: null,
+ assistantMessageId: null,
+ },
+ session: null,
+ };
+
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ queuedUserId,
+ ThreadId.makeUnsafe("thread-fork-before-assistant"),
+ ),
+ ).toEqual([]);
+ expect([...resolveThreadForkableMessageIds(thread)]).toEqual([
+ MessageId.makeUnsafe("message-prior-settled-user"),
+ activeUserId,
+ ]);
+ });
+
+ it("uses the earliest unsafe boundary when the active assistant projects after queued input", () => {
+ const priorUserId = MessageId.makeUnsafe("message-prior-before-interleaving");
+ const activeUserId = MessageId.makeUnsafe("message-active-before-interleaving");
+ const queuedUserId = MessageId.makeUnsafe("message-queued-before-active-assistant");
+ const activeAssistantId = MessageId.makeUnsafe("message-active-assistant-after-queued");
+ const activeTurnId = TurnId.makeUnsafe("turn-active-assistant-after-queued");
+ const requestedAt = "2026-07-22T08:02:00.000Z";
+ const thread = {
+ messages: [
+ {
+ id: priorUserId,
+ role: "user" as const,
+ text: "Settled prompt",
+ createdAt: "2026-07-22T08:01:00.000Z",
+ streaming: false,
+ },
+ {
+ id: activeUserId,
+ role: "user" as const,
+ text: "Active prompt",
+ createdAt: requestedAt,
+ streaming: false,
+ },
+ {
+ id: queuedUserId,
+ role: "user" as const,
+ text: "Queued prompt",
+ createdAt: "2026-07-22T08:02:01.000Z",
+ streaming: false,
+ },
+ {
+ id: activeAssistantId,
+ role: "assistant" as const,
+ text: "Delayed active response",
+ turnId: activeTurnId,
+ createdAt: "2026-07-22T08:02:02.000Z",
+ streaming: true,
+ },
+ ],
+ latestTurn: {
+ turnId: activeTurnId,
+ state: "running" as const,
+ requestedAt,
+ startedAt: requestedAt,
+ completedAt: null,
+ assistantMessageId: activeAssistantId,
+ },
+ session: null,
+ };
+
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ queuedUserId,
+ ThreadId.makeUnsafe("thread-fork-interleaved-queue"),
+ ),
+ ).toEqual([]);
+ expect([...resolveThreadForkableMessageIds(thread)]).toEqual([priorUserId, activeUserId]);
+ });
+
+ it("assigns equal-timestamp imports ids whose persisted sort preserves source order", () => {
+ const boundaryId = MessageId.makeUnsafe("message-equal-time-assistant");
+ const imported = buildThreadForkImportedMessagesThrough(
+ {
+ messages: [
+ {
+ id: MessageId.makeUnsafe("message-equal-time-user"),
+ role: "user",
+ text: "First",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: boundaryId,
+ role: "assistant",
+ text: "Second",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ ],
+ },
+ boundaryId,
+ ThreadId.makeUnsafe("thread-fork-equal-time"),
+ );
+
+ expect(imported.map((message) => message.text)).toEqual(["First", "Second"]);
+ expect(imported[0]!.messageId < imported[1]!.messageId).toBe(true);
+ });
+
+ it("treats an active-turn assistant as unsafe even before its streaming flag arrives", () => {
+ const assistantId = MessageId.makeUnsafe("message-active-nonstreaming-assistant");
+ const turnId = TurnId.makeUnsafe("turn-active-nonstreaming-assistant");
+ const thread = {
+ messages: [
+ {
+ id: assistantId,
+ role: "assistant" as const,
+ text: "Projected before the stream flag",
+ turnId,
+ createdAt: "2026-07-22T08:00:01.000Z",
+ streaming: false,
+ },
+ ],
+ latestTurn: {
+ turnId,
+ state: "running" as const,
+ requestedAt: "2026-07-22T08:00:00.000Z",
+ startedAt: "2026-07-22T08:00:00.500Z",
+ completedAt: null,
+ assistantMessageId: assistantId,
+ },
+ session: null,
+ };
+
+ expect(
+ buildThreadForkImportedMessagesThrough(
+ thread,
+ assistantId,
+ ThreadId.makeUnsafe("thread-fork-active"),
+ ),
+ ).toEqual([]);
+ expect(resolveThreadForkableMessageIds(thread).size).toBe(0);
+ });
+
+ it("imports the settled latest assistant when its raw streaming flag is stale", () => {
+ const assistantId = MessageId.makeUnsafe("message-stale-streaming-assistant");
+ const turnId = TurnId.makeUnsafe("turn-stale-streaming-assistant");
+
+ const imported = buildThreadForkImportedMessagesThrough(
+ {
+ messages: [
+ {
+ id: MessageId.makeUnsafe("message-stale-streaming-user"),
+ role: "user",
+ text: "Question",
+ createdAt: "2026-07-22T08:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: assistantId,
+ role: "assistant",
+ text: "Completed answer",
+ turnId,
+ createdAt: "2026-07-22T08:00:01.000Z",
+ completedAt: "2026-07-22T08:00:02.000Z",
+ streaming: true,
+ },
+ ],
+ latestTurn: {
+ turnId,
+ state: "completed",
+ requestedAt: "2026-07-22T08:00:00.000Z",
+ startedAt: "2026-07-22T08:00:00.500Z",
+ completedAt: "2026-07-22T08:00:02.000Z",
+ assistantMessageId: assistantId,
+ },
+ session: null,
+ },
+ assistantId,
+ ThreadId.makeUnsafe("thread-fork-settled"),
+ );
+
+ expect(imported.map(({ role, text }) => ({ role, text }))).toEqual([
+ { role: "user", text: "Question" },
+ { role: "assistant", text: "Completed answer" },
+ ]);
+ });
+
+ it("imports every settled stale-streaming assistant row from the terminal turn", () => {
+ const firstAssistantId = MessageId.makeUnsafe("message-stale-streaming-assistant-first");
+ const latestAssistantId = MessageId.makeUnsafe("message-stale-streaming-assistant-latest");
+ const turnId = TurnId.makeUnsafe("turn-stale-streaming-assistants");
+
+ const imported = buildThreadForkImportedMessagesThrough(
+ {
+ messages: [
+ {
+ id: firstAssistantId,
+ role: "assistant",
+ text: "First completed row",
+ turnId,
+ createdAt: "2026-07-22T08:00:01.000Z",
+ streaming: true,
+ },
+ {
+ id: latestAssistantId,
+ role: "assistant",
+ text: "Latest completed row",
+ turnId,
+ createdAt: "2026-07-22T08:00:02.000Z",
+ streaming: true,
+ },
+ ],
+ latestTurn: {
+ turnId,
+ state: "completed",
+ requestedAt: "2026-07-22T08:00:00.000Z",
+ startedAt: "2026-07-22T08:00:00.500Z",
+ completedAt: "2026-07-22T08:00:03.000Z",
+ assistantMessageId: latestAssistantId,
+ },
+ session: null,
+ },
+ latestAssistantId,
+ ThreadId.makeUnsafe("thread-fork-settled-multiple"),
+ );
+
+ expect(imported.map(({ text }) => text)).toEqual([
+ "First completed row",
+ "Latest completed row",
+ ]);
+ });
+
it("lists all supported handoff targets except the active provider", () => {
const providers = [
"codex",
diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts
index 1235d489..91b9433c 100644
--- a/apps/web/src/lib/threadHandoff.ts
+++ b/apps/web/src/lib/threadHandoff.ts
@@ -6,6 +6,7 @@
import {
EventId,
MessageId,
+ ThreadId,
type OrchestrationThreadActivity,
PROVIDER_DISPLAY_NAMES,
type ModelSelection,
@@ -13,6 +14,7 @@ import {
type ThreadHandoffImportedMessage,
} from "@synara/contracts";
import { getDefaultModel } from "@synara/shared/model";
+import { isLatestTurnSettled } from "../session-logic";
import { type Thread } from "../types";
import { stripEmbeddedAssistantSelections } from "./assistantSelections";
import { randomUUID } from "./utils";
@@ -43,6 +45,64 @@ function isImportableThreadMessage(
return (message.role === "user" || message.role === "assistant") && message.streaming === false;
}
+type ForkSourceThread = Pick & Partial>;
+
+function resolveRunningTurnBoundaryIndex(thread: ForkSourceThread): number | null {
+ if (!thread.latestTurn || isLatestTurnSettled(thread.latestTurn, thread.session ?? null)) {
+ return null;
+ }
+
+ const activeAssistantIndex = thread.messages.findIndex(
+ (message) => message.role === "assistant" && message.turnId === thread.latestTurn?.turnId,
+ );
+ const requestedUserIndex = thread.messages.findIndex(
+ (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt,
+ );
+
+ const knownUnsafeIndexes = [
+ ...(activeAssistantIndex >= 0 ? [activeAssistantIndex] : []),
+ ...(requestedUserIndex >= 0 ? [requestedUserIndex + 1] : []),
+ ];
+ if (knownUnsafeIndexes.length > 0) {
+ return Math.min(...knownUnsafeIndexes);
+ }
+
+ return 0;
+}
+
+function isSettledTerminalTurnAssistantMessage(
+ thread: ForkSourceThread,
+ message: Thread["messages"][number],
+): message is Thread["messages"][number] & { role: "assistant" } {
+ return (
+ message.role === "assistant" &&
+ message.streaming === true &&
+ message.turnId != null &&
+ thread.latestTurn?.turnId === message.turnId &&
+ isLatestTurnSettled(thread.latestTurn, thread.session ?? null)
+ );
+}
+
+function isForkImportableThreadMessage(
+ thread: ForkSourceThread,
+ message: Thread["messages"][number],
+): message is Thread["messages"][number] & { role: "user" | "assistant" } {
+ if (message.role !== "user" && message.role !== "assistant") {
+ return false;
+ }
+ if (
+ message.role === "assistant" &&
+ message.turnId != null &&
+ thread.latestTurn?.turnId === message.turnId &&
+ !isLatestTurnSettled(thread.latestTurn, thread.session ?? null)
+ ) {
+ return false;
+ }
+ return (
+ isImportableThreadMessage(message) || isSettledTerminalTurnAssistantMessage(thread, message)
+ );
+}
+
function isImportableThreadActivity(
activity: Thread["activities"][number],
): activity is OrchestrationThreadActivity {
@@ -71,37 +131,116 @@ export function resolveThreadHandoffTitle(thread: Pick): string
export function buildThreadHandoffImportedMessages(
thread: Pick,
): ReadonlyArray {
- return thread.messages.filter(isImportableThreadMessage).map((message) => {
- const importedText =
- message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text;
- const importedMessage: ThreadHandoffImportedMessage = {
- messageId: MessageId.makeUnsafe(randomUUID()),
- role: message.role,
- text: importedText,
- createdAt: message.createdAt,
- updatedAt: message.completedAt ?? message.createdAt,
- };
- const attachments =
- message.attachments && message.attachments.length > 0
- ? message.attachments.map((attachment) =>
- attachment.type === "assistant-selection"
- ? {
- type: attachment.type,
- id: attachment.id,
- assistantMessageId: attachment.assistantMessageId,
- text: attachment.text,
- }
- : {
- type: attachment.type,
- id: attachment.id,
- name: attachment.name,
- mimeType: attachment.mimeType,
- sizeBytes: attachment.sizeBytes,
- },
- )
- : null;
- return attachments ? Object.assign(importedMessage, { attachments }) : importedMessage;
- });
+ return buildImportedMessages(thread.messages);
+}
+
+function buildImportedMessages(
+ messages: ReadonlyArray,
+ isImportable: (message: Thread["messages"][number]) => boolean = isImportableThreadMessage,
+ makeMessageId: (index: number) => MessageId = () => MessageId.makeUnsafe(randomUUID()),
+): ReadonlyArray {
+ return messages
+ .filter(
+ (message): message is Thread["messages"][number] & { role: "user" | "assistant" } =>
+ isImportable(message) && (message.role === "user" || message.role === "assistant"),
+ )
+ .map((message, index) => {
+ const importedText =
+ message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text;
+ const importedMessage: ThreadHandoffImportedMessage = {
+ messageId: makeMessageId(index),
+ role: message.role,
+ text: importedText,
+ createdAt: message.createdAt,
+ updatedAt: message.completedAt ?? message.createdAt,
+ };
+ const attachments =
+ message.attachments && message.attachments.length > 0
+ ? message.attachments.map((attachment) =>
+ attachment.type === "assistant-selection"
+ ? {
+ type: attachment.type,
+ id: attachment.id,
+ assistantMessageId: attachment.assistantMessageId,
+ text: attachment.text,
+ }
+ : {
+ type: attachment.type,
+ id: attachment.id,
+ name: attachment.name,
+ mimeType: attachment.mimeType,
+ sizeBytes: attachment.sizeBytes,
+ },
+ )
+ : null;
+ return attachments ? Object.assign(importedMessage, { attachments }) : importedMessage;
+ });
+}
+
+export function buildThreadForkImportedMessagesThrough(
+ thread: ForkSourceThread,
+ sourceMessageId: MessageId,
+ destinationThreadId: ThreadId,
+): ReadonlyArray {
+ const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId);
+ const sourceMessage = thread.messages[sourceMessageIndex];
+ const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread);
+ if (
+ sourceMessageIndex < 0 ||
+ !sourceMessage ||
+ (runningTurnBoundaryIndex !== null && sourceMessageIndex >= runningTurnBoundaryIndex)
+ ) {
+ return [];
+ }
+
+ const conversationPrefix = thread.messages
+ .slice(0, sourceMessageIndex + 1)
+ .filter((message) => message.role === "user" || message.role === "assistant");
+ if (
+ conversationPrefix.length === 0 ||
+ conversationPrefix.some((message) => !isForkImportableThreadMessage(thread, message)) ||
+ conversationPrefix.some((message, index, messages) => {
+ const previous = messages[index - 1];
+ return previous !== undefined && previous.createdAt > message.createdAt;
+ })
+ ) {
+ return [];
+ }
+
+ return buildImportedMessages(
+ conversationPrefix,
+ (message) => isForkImportableThreadMessage(thread, message),
+ (index) =>
+ MessageId.makeUnsafe(
+ `fork:${destinationThreadId}:${String(index).padStart(8, "0")}:${randomUUID()}`,
+ ),
+ );
+}
+
+/**
+ * Message actions are offered only while the entire conversation prefix is safe
+ * to import. Once a live assistant row appears, that row and every later user
+ * or assistant boundary stay hidden until the lifecycle settles.
+ */
+export function resolveThreadForkableMessageIds(thread: ForkSourceThread): ReadonlySet {
+ const forkableMessageIds = new Set();
+ let prefixIsImportable = true;
+ const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread);
+
+ for (const [messageIndex, message] of thread.messages.entries()) {
+ if (message.role !== "user" && message.role !== "assistant") {
+ continue;
+ }
+ prefixIsImportable =
+ prefixIsImportable &&
+ (runningTurnBoundaryIndex === null || messageIndex < runningTurnBoundaryIndex) &&
+ isForkImportableThreadMessage(thread, message);
+ if (prefixIsImportable) {
+ forkableMessageIds.add(message.id);
+ }
+ }
+
+ return forkableMessageIds;
}
export function buildThreadHandoffImportedActivities(
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 556ca927..52425539 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -387,6 +387,10 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
(left.subagentNickname ?? null) === (right.subagentNickname ?? null) &&
(left.subagentRole ?? null) === (right.subagentRole ?? null) &&
(left.forkSourceThreadId ?? null) === (right.forkSourceThreadId ?? null) &&
+ (left.forkSourceMessageId ?? null) === (right.forkSourceMessageId ?? null) &&
+ (left.forkTitleFamilyRootId ?? null) === (right.forkTitleFamilyRootId ?? null) &&
+ (left.forkTitleBase ?? null) === (right.forkTitleBase ?? null) &&
+ (left.forkTitleOrdinal ?? null) === (right.forkTitleOrdinal ?? null) &&
(left.sidechatSourceThreadId ?? null) === (right.sidechatSourceThreadId ?? null) &&
deepEqualJson(left.lastKnownPr ?? null, right.lastKnownPr ?? null) &&
(left.handoff ?? null) === (right.handoff ?? null) &&
@@ -435,6 +439,10 @@ function toThreadShell(thread: Thread): ThreadShell {
subagentNickname: thread.subagentNickname ?? null,
subagentRole: thread.subagentRole ?? null,
forkSourceThreadId: thread.forkSourceThreadId ?? null,
+ forkSourceMessageId: thread.forkSourceMessageId ?? null,
+ forkTitleFamilyRootId: thread.forkTitleFamilyRootId ?? null,
+ forkTitleBase: thread.forkTitleBase ?? null,
+ forkTitleOrdinal: thread.forkTitleOrdinal ?? null,
sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null,
lastKnownPr: thread.lastKnownPr ?? null,
handoff: thread.handoff ?? null,
@@ -1748,6 +1756,10 @@ function normalizeThreadFromReadModel(
previous.hasPendingUserInput === resolvedHasPendingUserInput &&
previous.hasActionableProposedPlan === resolvedHasActionableProposedPlan &&
(previous.forkSourceThreadId ?? null) === (incoming.forkSourceThreadId ?? null) &&
+ (previous.forkSourceMessageId ?? null) === (incoming.forkSourceMessageId ?? null) &&
+ (previous.forkTitleFamilyRootId ?? null) === (incoming.forkTitleFamilyRootId ?? null) &&
+ (previous.forkTitleBase ?? null) === (incoming.forkTitleBase ?? null) &&
+ (previous.forkTitleOrdinal ?? null) === (incoming.forkTitleOrdinal ?? null) &&
(previous.sidechatSourceThreadId ?? null) === (incoming.sidechatSourceThreadId ?? null) &&
deepEqualJson(previous.lastKnownPr ?? null, lastKnownPr) &&
(previous.handoff ?? null) === handoff &&
@@ -1791,6 +1803,10 @@ function normalizeThreadFromReadModel(
associatedWorktreeRef: nextAssociatedWorktreeRef,
createBranchFlowCompleted: resolvedCreateBranchFlowCompleted,
forkSourceThreadId: incoming.forkSourceThreadId ?? null,
+ forkSourceMessageId: incoming.forkSourceMessageId ?? null,
+ forkTitleFamilyRootId: incoming.forkTitleFamilyRootId ?? null,
+ forkTitleBase: incoming.forkTitleBase ?? null,
+ forkTitleOrdinal: incoming.forkTitleOrdinal ?? null,
sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null,
lastKnownPr,
handoff,
@@ -1884,6 +1900,10 @@ function normalizeThreadShellSnapshot(
subagentNickname: incoming.subagentNickname ?? null,
subagentRole: incoming.subagentRole ?? null,
forkSourceThreadId: incoming.forkSourceThreadId ?? null,
+ forkSourceMessageId: incoming.forkSourceMessageId ?? null,
+ forkTitleFamilyRootId: incoming.forkTitleFamilyRootId ?? null,
+ forkTitleBase: incoming.forkTitleBase ?? null,
+ forkTitleOrdinal: incoming.forkTitleOrdinal ?? null,
sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null,
lastKnownPr,
handoff,
@@ -2188,6 +2208,7 @@ function sidebarThreadSummariesEqual(
left.hasActionableProposedPlan === right.hasActionableProposedPlan &&
left.hasLiveTailWork === right.hasLiveTailWork &&
(left.forkSourceThreadId ?? null) === (right.forkSourceThreadId ?? null) &&
+ (left.forkSourceMessageId ?? null) === (right.forkSourceMessageId ?? null) &&
(left.sidechatSourceThreadId ?? null) === (right.sidechatSourceThreadId ?? null) &&
deepEqualJson(left.lastKnownPr ?? null, right.lastKnownPr ?? null) &&
(left.handoff ?? null) === (right.handoff ?? null)
@@ -2230,6 +2251,7 @@ function buildSidebarThreadSummary(
hasActionableProposedPlan: metadata.hasActionableProposedPlan,
hasLiveTailWork: metadata.hasLiveTailWork,
forkSourceThreadId: thread.forkSourceThreadId ?? null,
+ forkSourceMessageId: thread.forkSourceMessageId ?? null,
sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null,
lastKnownPr: thread.lastKnownPr ?? null,
handoff: thread.handoff ?? null,
diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts
index 360a2215..931499cf 100644
--- a/apps/web/src/test/effectRpcWebSocketMock.ts
+++ b/apps/web/src/test/effectRpcWebSocketMock.ts
@@ -147,6 +147,10 @@ export function createShellSnapshotFromReadModel(
subagentNickname: thread.subagentNickname ?? null,
subagentRole: thread.subagentRole ?? null,
forkSourceThreadId: thread.forkSourceThreadId ?? null,
+ forkSourceMessageId: thread.forkSourceMessageId ?? null,
+ forkTitleFamilyRootId: thread.forkTitleFamilyRootId ?? null,
+ forkTitleBase: thread.forkTitleBase ?? null,
+ forkTitleOrdinal: thread.forkTitleOrdinal ?? null,
sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null,
latestTurn: thread.latestTurn,
latestUserMessageAt: thread.latestUserMessageAt ?? null,
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index bd77646e..70aa9a64 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -226,6 +226,10 @@ export interface Thread extends ThreadWorkspaceState {
subagentNickname?: string | null;
subagentRole?: string | null;
forkSourceThreadId?: ThreadId | null;
+ forkSourceMessageId?: MessageId | null;
+ forkTitleFamilyRootId?: ThreadId | null;
+ forkTitleBase?: string | null;
+ forkTitleOrdinal?: number | null;
sidechatSourceThreadId?: ThreadId | null;
handoff?: ThreadHandoff | null;
lastKnownPr?: OrchestrationThreadPullRequest | null;
@@ -262,6 +266,10 @@ export interface ThreadShell extends ThreadWorkspaceState {
subagentNickname?: string | null;
subagentRole?: string | null;
forkSourceThreadId?: ThreadId | null;
+ forkSourceMessageId?: MessageId | null;
+ forkTitleFamilyRootId?: ThreadId | null;
+ forkTitleBase?: string | null;
+ forkTitleOrdinal?: number | null;
sidechatSourceThreadId?: ThreadId | null;
handoff?: ThreadHandoff | null;
lastKnownPr?: OrchestrationThreadPullRequest | null;
@@ -306,6 +314,7 @@ export interface SidebarThreadSummary {
hasActionableProposedPlan: boolean;
hasLiveTailWork: boolean;
forkSourceThreadId?: ThreadId | null;
+ forkSourceMessageId?: MessageId | null;
sidechatSourceThreadId?: ThreadId | null;
handoff?: ThreadHandoff | null;
lastKnownPr?: OrchestrationThreadPullRequest | null;
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 169ae6f6..b2326be2 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -670,6 +670,18 @@ export const OrchestrationThread = Schema.Struct({
forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
Schema.withDecodingDefault(() => null),
),
+ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
sidechatSourceThreadId: SidechatSourceThreadId,
lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe(
Schema.withDecodingDefault(() => null),
@@ -737,6 +749,18 @@ export const OrchestrationThreadShell = Schema.Struct({
forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
Schema.withDecodingDefault(() => null),
),
+ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
sidechatSourceThreadId: SidechatSourceThreadId,
lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe(
Schema.withDecodingDefault(() => null),
@@ -920,6 +944,7 @@ const ThreadForkCreateCommand = Schema.Struct({
commandId: CommandId,
threadId: ThreadId,
sourceThreadId: ThreadId,
+ sourceMessageId: Schema.optional(MessageId),
projectId: ProjectId,
title: TrimmedNonEmptyString,
modelSelection: ModelSelection,
@@ -1490,6 +1515,18 @@ export const ThreadCreatedPayload = Schema.Struct({
forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
Schema.withDecodingDefault(() => null),
),
+ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
+ forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe(
+ Schema.withDecodingDefault(() => null),
+ ),
sidechatSourceThreadId: SidechatSourceThreadId,
lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe(
Schema.withDecodingDefault(() => null),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 03d29e24..57805fbb 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -72,6 +72,10 @@
"types": "./src/chatThreads.ts",
"import": "./src/chatThreads.ts"
},
+ "./assistantSelections": {
+ "types": "./src/assistantSelections.ts",
+ "import": "./src/assistantSelections.ts"
+ },
"./localServers": {
"types": "./src/localServers.ts",
"import": "./src/localServers.ts"
diff --git a/packages/shared/src/assistantSelections.ts b/packages/shared/src/assistantSelections.ts
new file mode 100644
index 00000000..038b439f
--- /dev/null
+++ b/packages/shared/src/assistantSelections.ts
@@ -0,0 +1,10 @@
+// FILE: assistantSelections.ts
+// Purpose: Share persisted-prompt cleanup used when conversation history is copied.
+// Layer: Shared text normalization
+
+const EMBEDDED_ASSISTANT_SELECTIONS_PATTERN =
+ /\n*\n[\s\S]*?\n<\/assistant_selection>(?=\n*(\n[\s\S]*?\n<\/terminal_context>\s*)?(\n[\s\S]*?\n<\/file_comments>\s*)?(\n[\s\S]*?\n<\/pasted_text>\s*)?$)/;
+
+export function stripEmbeddedAssistantSelections(prompt: string): string {
+ return prompt.replace(EMBEDDED_ASSISTANT_SELECTIONS_PATTERN, "");
+}