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
25 changes: 18 additions & 7 deletions apps/server/src/threadTabs/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand All @@ -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)}`,
Expand Down
65 changes: 64 additions & 1 deletion apps/server/src/threadTabs/summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
});
});
19 changes: 19 additions & 0 deletions apps/server/src/threadTabs/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2256,7 +2256,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
)}
{ctx.onForkFromMessage && (
<ForkUserMessageButton messageId={row.message.id} onFork={ctx.onForkFromMessage} />
<ForkMessageButton messageId={row.message.id} onFork={ctx.onForkFromMessage} />
)}
{resolvedContext.text && (
<MessageCopyButton
Expand Down Expand Up @@ -2337,7 +2337,7 @@ function RevertUserMessageButton({
);
}

function ForkUserMessageButton({
function ForkMessageButton({
messageId,
onFork,
}: {
Expand Down Expand Up @@ -2516,6 +2516,9 @@ function AssistantMessageMeta({
className,
)}
>
{ctx.onForkFromMessage && !message.streaming && (
<ForkMessageButton messageId={message.id} onFork={ctx.onForkFromMessage} />
)}
<AssistantCopyButton
message={message}
showCopyButton={showCopyButton}
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/components/chat/ThreadTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,18 @@ function ThreadTabContextPill(props: {
/** The transcript summary of a sibling tab, as captured into the draft of `threadId`. */
async function fetchThreadTabSummary(
connection: PreparedConnection,
input: { threadId: ThreadId; sourceThreadId: ThreadId; beforeMessageId?: MessageId },
input: {
threadId: ThreadId;
sourceThreadId: ThreadId;
beforeMessageId?: MessageId;
afterMessageId?: MessageId;
},
): Promise<string> {
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);
Expand All @@ -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(
Expand All @@ -398,6 +405,7 @@ async function captureThreadTabContext(
sourceThreadId: ThreadId;
title: string;
beforeMessageId?: MessageId;
afterMessageId?: MessageId;
summary?: string;
},
): Promise<ComposerContextReference> {
Expand Down Expand Up @@ -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(
Expand All @@ -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. */
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/threadTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down