diff --git a/apps/server/src/threadTabs/http.ts b/apps/server/src/threadTabs/http.ts
index 5edb39fd1f348..0b0e4420790bd 100644
--- a/apps/server/src/threadTabs/http.ts
+++ b/apps/server/src/threadTabs/http.ts
@@ -21,7 +21,11 @@ import {
} from "../auth/http.ts";
import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
-import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts";
+import {
+ siblingChatBeforeMessage,
+ siblingChatThroughMessage,
+ summarizeSiblingChat,
+} from "./summary.ts";
interface TabRow {
readonly threadId: string;
@@ -187,16 +191,21 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group(
);
if (!group) return yield* failEnvironmentNotFound("thread_not_found");
const sourceIds = [...new Set(args.payload.sourceThreadIds)];
- const { beforeMessageId } = args.payload;
+ const { beforeMessageId, afterMessageId } = args.payload;
if (
sourceIds.includes(args.params.threadId) ||
sourceIds.some((id) => !group.tabs.some((tab) => tab.threadId === id)) ||
- (beforeMessageId !== undefined && sourceIds.length !== 1)
+ ((beforeMessageId !== undefined || afterMessageId !== undefined) &&
+ sourceIds.length !== 1) ||
+ (beforeMessageId !== undefined && afterMessageId !== undefined)
) {
return yield* failEnvironmentInvalidRequest("invalid_command");
}
// A fork cuts the history at its message, which may be older than the recent window.
- const window = beforeMessageId === undefined ? { turnLimit: 8 } : undefined;
+ const window =
+ beforeMessageId === undefined && afterMessageId === undefined
+ ? { turnLimit: 8 }
+ : undefined;
const sections = yield* Effect.forEach(sourceIds, (sourceId) =>
snapshots.getThreadDetailSnapshot(sourceId, window).pipe(
Effect.catch((cause) => failEnvironmentInternal("internal_error", cause)),
@@ -214,9 +223,11 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group(
proposedPlans: thread.proposedPlans,
};
const source =
- beforeMessageId === undefined
- ? chat
- : siblingChatBeforeMessage(chat, beforeMessageId);
+ afterMessageId !== undefined
+ ? siblingChatThroughMessage(chat, afterMessageId)
+ : beforeMessageId !== undefined
+ ? siblingChatBeforeMessage(chat, beforeMessageId)
+ : chat;
if (!source) return failEnvironmentInvalidRequest("invalid_command");
return Effect.succeed(
`Source thread: ${sourceId} (snapshot ${snapshotSequence})\n${summarizeSiblingChat(source)}`,
diff --git a/apps/server/src/threadTabs/summary.test.ts b/apps/server/src/threadTabs/summary.test.ts
index 620bca0950b47..faef23002e6a4 100644
--- a/apps/server/src/threadTabs/summary.test.ts
+++ b/apps/server/src/threadTabs/summary.test.ts
@@ -8,7 +8,11 @@ import {
type OrchestrationMessage,
type OrchestrationThreadActivity,
} from "@t3tools/contracts";
-import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts";
+import {
+ siblingChatBeforeMessage,
+ siblingChatThroughMessage,
+ summarizeSiblingChat,
+} from "./summary.ts";
let clock = 0;
// Strictly increasing timestamps order the fixture chronologically.
@@ -204,4 +208,63 @@ describe("sibling chat handoff", () => {
expect(siblingChatBeforeMessage(chat, "a1")).toBeNull();
expect(siblingChatBeforeMessage(chat, "missing")).toBeNull();
});
+ it("forks through a completed assistant response without later dialogue or work", () => {
+ const opening = message("u1", "user", "Build search");
+ const answer = message("a1", "assistant", "Built search");
+ // Tool work can land while the assistant response is streaming.
+ const earlyTool = activity("tool.completed", "Read search.ts", {});
+ const earlyFiles = checkpoint([
+ { path: "search.ts", kind: "modified", additions: 1, deletions: 0 },
+ ]);
+ const plan = {
+ id: "early-plan",
+ turnId: null,
+ planMarkdown: "Build the search view",
+ implementedAt: null,
+ implementationThreadId: null,
+ createdAt: at(),
+ updatedAt: at(),
+ };
+ const completedAnswer = { ...answer, updatedAt: at() };
+ const later = message("u2", "user", "Add filters");
+ const lateTool = activity("tool.completed", "Read filters.ts", {});
+ const lateFiles = checkpoint([
+ { path: "filters.ts", kind: "modified", additions: 4, deletions: 0 },
+ ]);
+ const chat = {
+ title: "Search",
+ latestTurnState: "running",
+ messages: [opening, completedAnswer, later, message("a2", "assistant", "Added filters")],
+ activities: [earlyTool, lateTool],
+ checkpoints: [earlyFiles, lateFiles],
+ proposedPlans: [
+ plan,
+ { ...plan, id: "late-plan", planMarkdown: "Add filters", updatedAt: at() },
+ ],
+ };
+ const forked = siblingChatThroughMessage(chat, "a1");
+ expect(forked?.messages.map((entry) => entry.id)).toEqual(["u1", "a1"]);
+ const summary = summarizeSiblingChat(forked!);
+ expect(summary).toContain("User: Build search");
+ expect(summary).toContain("Assistant: Built search");
+ expect(summary).toContain("Read search.ts");
+ expect(summary).toContain("search.ts (+1 −0)");
+ expect(summary).toContain("Latest plan:\nBuild the search view");
+ expect(summary).not.toContain("filters");
+ expect(summary).not.toContain("Latest turn");
+ });
+
+ it("rejects assistant fork cutoffs that are missing, streaming, or another role", () => {
+ const chat = {
+ title: "Search",
+ messages: [
+ message("u1", "user", "Build search"),
+ message("a1", "assistant", "Working", true),
+ message("r1", "reasoning", "Thinking"),
+ ],
+ };
+ for (const id of ["missing", "u1", "a1", "r1"]) {
+ expect(siblingChatThroughMessage(chat, id)).toBeNull();
+ }
+ });
});
diff --git a/apps/server/src/threadTabs/summary.ts b/apps/server/src/threadTabs/summary.ts
index 2acd049a6d38e..401c6baebdf78 100644
--- a/apps/server/src/threadTabs/summary.ts
+++ b/apps/server/src/threadTabs/summary.ts
@@ -50,6 +50,25 @@ export function siblingChatBeforeMessage(
};
}
+/** The chat through a completed assistant response, ready for a follow-up in a new tab. */
+export function siblingChatThroughMessage(
+ source: SiblingChatSource,
+ messageId: string,
+): SiblingChatSource | null {
+ const index = source.messages.findIndex((message) => message.id === messageId);
+ const cutoff = source.messages[index];
+ if (!cutoff || cutoff.role !== "assistant" || cutoff.streaming) return null;
+ const through = (at: string) => at <= cutoff.updatedAt;
+ return {
+ ...source,
+ latestTurnState: null,
+ messages: source.messages.slice(0, index + 1),
+ activities: (source.activities ?? []).filter((activity) => through(activity.createdAt)),
+ checkpoints: (source.checkpoints ?? []).filter((checkpoint) => through(checkpoint.completedAt)),
+ proposedPlans: (source.proposedPlans ?? []).filter((plan) => through(plan.updatedAt)),
+ };
+}
+
type Entry =
| { readonly at: string; readonly kind: "message"; readonly message: OrchestrationMessage }
| { readonly at: string; readonly kind: "tool"; readonly label: string }
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 3d9cc738029f5..627d3b546d0a0 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -7318,13 +7318,31 @@ export default function ChatView(props: ChatViewProps) {
params: { environmentId: tabRef.environmentId, threadId: tabRef.threadId },
});
- // From a user message: the summary stops before it, and the message follows for a retry.
+ // User messages are retried after the summary; assistant responses are included for a follow-up.
const onForkFromMessage = (messageId: MessageId) =>
runFork(async (connection) => {
if (!activeThread) return;
const index = activeThread.messages.findIndex((message) => message.id === messageId);
const message = activeThread.messages[index];
- if (!message || message.role !== "user") return;
+ if (
+ !message ||
+ (message.role !== "user" && message.role !== "assistant") ||
+ message.streaming
+ )
+ return;
+ if (message.role === "assistant") {
+ const tabRef = await forkThreadTab(connection, {
+ environmentId,
+ sourceThreadId: activeThread.id,
+ sourceTitle: activeThread.title,
+ modelSelection: activeThread.modelSelection,
+ afterMessageId: messageId,
+ prompt: "",
+ hasHistory: true,
+ });
+ await openForkedTab(tabRef);
+ return;
+ }
const files = await prepareRevertedMessageAttachments({
message,
environmentId,
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index d547086c6bc73..25cc8a47078c4 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -2256,7 +2256,7 @@ function UserTimelineRow({ row }: { row: Extract
)}
{ctx.onForkFromMessage && (
-
+
)}
{resolvedContext.text && (
+ {ctx.onForkFromMessage && !message.streaming && (
+
+ )}
{
const handoff = await runtime.runPromise(
prepareThreadTabHandoff(connection, input.threadId, {
sourceThreadIds: [input.sourceThreadId],
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
+ ...(input.afterMessageId ? { afterMessageId: input.afterMessageId } : {}),
}),
);
return handoff.text.slice(0, COMPOSER_CONTEXT_THREAD_TAB_SUMMARY_MAX_CHARS);
@@ -388,7 +394,8 @@ async function fetchThreadTabSummary(
/**
* Captures a sibling tab's transcript summary into the draft of `threadId` and resolves to the
* chip reference for the composer to place. With `beforeMessageId`, only the history before
- * that user message of the source is summarized. A `summary` already fetched for a preview is
+ * that user message of the source is summarized; `afterMessageId` includes the assistant response.
+ * A `summary` already fetched for a preview is
* used as is.
*/
async function captureThreadTabContext(
@@ -398,6 +405,7 @@ async function captureThreadTabContext(
sourceThreadId: ThreadId;
title: string;
beforeMessageId?: MessageId;
+ afterMessageId?: MessageId;
summary?: string;
},
): Promise {
@@ -442,7 +450,7 @@ export function useCaptureThreadTabContext(
/**
* Forks a chat into a new tab of the same thread, on `modelSelection`: the new draft holds a
- * summary of the source chat, cut before `beforeMessageId` when forking from a message, followed
+ * summary of the source chat, cut before `beforeMessageId` or through `afterMessageId`, followed
* by `prompt`. Resolves to the new tab once the client knows about it.
*/
export async function forkThreadTab(
@@ -453,6 +461,7 @@ export async function forkThreadTab(
sourceTitle: string;
modelSelection: ModelSelection;
beforeMessageId?: MessageId;
+ afterMessageId?: MessageId;
/** Composer text to follow the summary. */
prompt: string;
/** False when forking from the chat's first message, which leaves nothing to summarize. */
@@ -472,6 +481,7 @@ export async function forkThreadTab(
sourceThreadId: input.sourceThreadId,
title: input.sourceTitle,
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
+ ...(input.afterMessageId ? { afterMessageId: input.afterMessageId } : {}),
})
: null;
const threadRef = scopeThreadRef(input.environmentId, threadId);
diff --git a/docs/fork-differences.md b/docs/fork-differences.md
index f38304a5f588b..31f9af1430053 100644
--- a/docs/fork-differences.md
+++ b/docs/fork-differences.md
@@ -171,6 +171,9 @@ is its own conversation and provider.
- A user message's hover actions include **Fork into new tab**. The summary stops before that
message (`beforeMessageId` on the handoff request), and the message's text and attachments
follow it.
+ - A completed agent response has the same fork action. Its summary includes that response
+ (`afterMessageId` on the handoff request); the new draft holds only the summary attachment,
+ ready for a follow-up.
- Each model picker row has a hover fork button. The new tab runs that model, the whole chat is
summarized, and the current draft (text, attachments, and context chips) is copied after it.
Other providers, and models the provider cannot switch to mid-chat, stay listed instead of
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 3ff59f3c11c3e..812e70f692b7c 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -45,6 +45,8 @@ changes.
To retry a message with another model or provider on web or desktop, hover the message and click
**Fork into new tab**. The new tab's composer holds a summary of the chat up to that message,
followed by the message itself and its attachments. Pick a model, edit if you like, and send.
+To continue from an agent response, use its **Fork into new tab** action. The new composer holds
+only a summary attachment covering the chat through that response, ready for your follow-up.
Files the original chat changed after that message stay changed, since tabs share the workspace.
To carry on with a different model instead, open the model picker after the first message and
diff --git a/packages/contracts/src/threadTabs.ts b/packages/contracts/src/threadTabs.ts
index 2becb14f27032..9e1fe5fb95625 100644
--- a/packages/contracts/src/threadTabs.ts
+++ b/packages/contracts/src/threadTabs.ts
@@ -34,6 +34,8 @@ export const ThreadTabHandoffInput = Schema.Struct({
sourceThreadIds: Schema.Array(ThreadId).check(Schema.isMaxLength(8)),
/** Forking: summarize only what came before this user message of the single source. */
beforeMessageId: Schema.optional(MessageId),
+ /** Forking: summarize through this completed assistant message of the single source. */
+ afterMessageId: Schema.optional(MessageId),
});
export type ThreadTabHandoffInput = typeof ThreadTabHandoffInput.Type;