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
45 changes: 28 additions & 17 deletions apps/server/src/threadTabs/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from "../auth/http.ts";
import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import { summarizeSiblingChat } from "./summary.ts";
import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts";

interface TabRow {
readonly threadId: string;
Expand Down Expand Up @@ -187,32 +187,43 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group(
);
if (!group) return yield* failEnvironmentNotFound("thread_not_found");
const sourceIds = [...new Set(args.payload.sourceThreadIds)];
const { beforeMessageId } = args.payload;
if (
sourceIds.includes(args.params.threadId) ||
sourceIds.some((id) => !group.tabs.some((tab) => tab.threadId === id))
sourceIds.some((id) => !group.tabs.some((tab) => tab.threadId === id)) ||
(beforeMessageId !== undefined && sourceIds.length !== 1)
) {
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 sections = yield* Effect.forEach(sourceIds, (sourceId) =>
snapshots.getThreadDetailSnapshot(sourceId, { turnLimit: 8 }).pipe(
Effect.map((detail) => {
snapshots.getThreadDetailSnapshot(sourceId, window).pipe(
Effect.catch((cause) => failEnvironmentInternal("internal_error", cause)),
Effect.flatMap((detail) => {
const tab = group.tabs.find((entry) => entry.threadId === sourceId);
if (Option.isNone(detail) || !tab) return "";
if (Option.isNone(detail) || !tab) return Effect.succeed("");
const { thread, snapshotSequence } = detail.value;
return `Source thread: ${sourceId} (snapshot ${snapshotSequence})\n${summarizeSiblingChat(
{
title: tab.title,
worktreePath: thread.worktreePath,
latestTurnState: thread.latestTurn?.state ?? null,
messages: thread.messages,
activities: thread.activities,
checkpoints: thread.checkpoints,
proposedPlans: thread.proposedPlans,
},
)}`;
const chat = {
title: tab.title,
worktreePath: thread.worktreePath,
latestTurnState: thread.latestTurn?.state ?? null,
messages: thread.messages,
activities: thread.activities,
checkpoints: thread.checkpoints,
proposedPlans: thread.proposedPlans,
};
const source =
beforeMessageId === undefined
? chat
: siblingChatBeforeMessage(chat, beforeMessageId);
if (!source) return failEnvironmentInvalidRequest("invalid_command");
return Effect.succeed(
`Source thread: ${sourceId} (snapshot ${snapshotSequence})\n${summarizeSiblingChat(source)}`,
);
}),
),
).pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause)));
);
return {
text: sections.filter(Boolean).join("\n\n"),
};
Expand Down
41 changes: 40 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,7 @@ import {
type OrchestrationMessage,
type OrchestrationThreadActivity,
} from "@t3tools/contracts";
import { summarizeSiblingChat } from "./summary.ts";
import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts";

let clock = 0;
// Strictly increasing timestamps order the fixture chronologically.
Expand Down Expand Up @@ -165,4 +165,43 @@ describe("sibling chat handoff", () => {
expect(summary).toContain("Conclusion");
expect(summary.length).toBeLessThan(3_000);
});

it("forks from a user message with only the history before it", () => {
const messages = [
message("u1", "user", "Build the search view"),
message("a1", "assistant", "Built it"),
];
const earlyTool = activity("tool.completed", "Read search.ts", { status: "completed" });
const earlyFiles = checkpoint([
{ path: "search.ts", kind: "modified", additions: 1, deletions: 0 },
]);
messages.push(message("u2", "user", "Now add filters"));
messages.push(message("a2", "assistant", "Added filters"));
const lateTool = activity("tool.completed", "Read filters.ts", { status: "completed" });
const lateFiles = checkpoint([
{ path: "filters.ts", kind: "modified", additions: 4, deletions: 0 },
]);
const chat = {
title: "Search",
latestTurnState: "completed",
messages,
activities: [earlyTool, lateTool],
checkpoints: [earlyFiles, lateFiles],
};

const forked = siblingChatBeforeMessage(chat, "u2");
expect(forked).not.toBeNull();
const summary = summarizeSiblingChat(forked!);
expect(summary).toContain("User: Build the search view");
expect(summary).toContain("Assistant: Built it");
expect(summary).toContain("Read search.ts");
expect(summary).toContain("search.ts (+1 −0)");
expect(summary).not.toContain("Now add filters");
expect(summary).not.toContain("Added filters");
expect(summary).not.toContain("filters.ts");
expect(summary).not.toContain("Latest turn");

expect(siblingChatBeforeMessage(chat, "a1")).toBeNull();
expect(siblingChatBeforeMessage(chat, "missing")).toBeNull();
});
});
23 changes: 23 additions & 0 deletions apps/server/src/threadTabs/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ export interface SiblingChatSource {
readonly proposedPlans?: ReadonlyArray<OrchestrationProposedPlan>;
}

/**
* The chat as it stood just before a user message, for forking from that message. Null when the
* message is not one of the chat's user messages.
*/
export function siblingChatBeforeMessage(
source: SiblingChatSource,
messageId: string,
): SiblingChatSource | null {
const index = source.messages.findIndex((message) => message.id === messageId);
const cutoff = source.messages[index];
if (!cutoff || cutoff.role !== "user") return null;
const before = (at: string) => at < cutoff.createdAt;
return {
...source,
// The latest turn is at or after the cutoff, so its state says nothing about this history.
latestTurnState: null,
messages: source.messages.slice(0, index),
activities: (source.activities ?? []).filter((activity) => before(activity.createdAt)),
checkpoints: (source.checkpoints ?? []).filter((checkpoint) => before(checkpoint.completedAt)),
proposedPlans: (source.proposedPlans ?? []).filter((plan) => before(plan.createdAt)),
};
}

type Entry =
| { readonly at: string; readonly kind: "message"; readonly message: OrchestrationMessage }
| { readonly at: string; readonly kind: "tool"; readonly label: string }
Expand Down
102 changes: 83 additions & 19 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic";
import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout";
import { ChatHeader } from "./chat/ChatHeader";
import { ThreadTabContextPills, useThreadTabGroup } from "./chat/ThreadTabs";
import { ThreadTabContextPills, forkThreadTab, useThreadTabGroup } from "./chat/ThreadTabs";
import { runtime } from "../lib/runtime";
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -1467,6 +1467,33 @@ const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3;
const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = [];
const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {};
const noopHeldRevert = (_targetTurnCount: number) => {};

/** Puts a sent message's downloaded attachments back into a composer draft, in their order. */
function restoreMessageAttachments(
target: ScopedThreadRef | DraftId,
message: ChatMessage,
files: ReadonlyArray<File>,
) {
const images: ComposerImageAttachment[] = [];
const restoredFiles: ComposerFileAttachment[] = [];
files.forEach((file, index) => {
const attachment = {
id: randomUUID(),
name: file.name,
mimeType: file.type,
sizeBytes: file.size,
file,
};
if (message.attachments?.[index]?.type === "image") {
images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
} else {
restoredFiles.push({ ...attachment, type: "file" });
}
});
const store = useComposerDraftStore.getState();
store.addImages(target, images, { allowDuplicates: true });
store.addFiles(target, restoredFiles, { allowDuplicates: true });
}
const noopHeldAttachment = (_attachment: ChatFileAttachment) => {};

/**
Expand Down Expand Up @@ -7113,24 +7140,7 @@ export default function ChatView(props: ChatViewProps) {
? `${currentPrompt}\n\n${restoredPrompt}`
: restoredPrompt;
store.setPrompt(composerDraftTarget, nextPrompt);
const images: ComposerImageAttachment[] = [];
const restoredFiles: ComposerFileAttachment[] = [];
files.forEach((file, index) => {
const attachment = {
id: randomUUID(),
name: file.name,
mimeType: file.type,
sizeBytes: file.size,
file,
};
if (message.attachments?.[index]?.type === "image") {
images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
} else {
restoredFiles.push({ ...attachment, type: "file" });
}
});
store.addImages(composerDraftTarget, images, { allowDuplicates: true });
store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true });
restoreMessageAttachments(composerDraftTarget, message, files);
if (currentRouteThreadKeyRef.current === routeThreadKey) {
promptRef.current = nextPrompt;
composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
Expand Down Expand Up @@ -7172,6 +7182,52 @@ export default function ChatView(props: ChatViewProps) {
],
);

// Forking opens a new tab whose draft repeats a user message after a summary of what came
// before it, so the message can be retried with another model or provider.
const forkInFlightRef = useRef(false);
const onForkFromMessage = async (messageId: MessageId) => {
if (!activeThread || !isServerThread || forkInFlightRef.current) return;
const index = activeThread.messages.findIndex((message) => message.id === messageId);
const message = activeThread.messages[index];
if (!message || message.role !== "user") return;
const connection = readPreparedConnection(environmentId);
if (!connection) {
toastManager.add({ type: "error", title: "The environment is not connected." });
return;
}
forkInFlightRef.current = true;
try {
const files = await prepareRevertedMessageAttachments({
message,
environmentId,
httpBaseUrl: connection.httpBaseUrl,
createAssetUrl: createAttachmentAssetUrl,
});
const tabRef = await forkThreadTab(connection, {
environmentId,
sourceThreadId: activeThread.id,
sourceTitle: activeThread.title,
modelSelection: activeThread.modelSelection,
messageId,
prompt: recallableComposerPrompt(message.text),
hasHistory: index > 0,
});
restoreMessageAttachments(tabRef, message, files);
await navigate({
to: "/$environmentId/$threadId",
params: { environmentId, threadId: tabRef.threadId },
});
} catch (cause) {
toastManager.add({
type: "error",
title: "Could not fork into a new tab",
description: cause instanceof Error ? cause.message : undefined,
});
} finally {
forkInFlightRef.current = false;
}
};

const onCompactContext = async () => {
if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) {
return;
Expand Down Expand Up @@ -9472,6 +9528,11 @@ export default function ChatView(props: ChatViewProps) {
const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => {
void onRevertToTurnCountRef.current(targetTurnCount, messageId);
}, []);
const onForkFromMessageRef = useRef(onForkFromMessage);
onForkFromMessageRef.current = onForkFromMessage;
const onForkTimelineMessage = useCallback((messageId: MessageId) => {
void onForkFromMessageRef.current(messageId);
}, []);

// Files dropped on a sidebar row land here once the dropped-on thread is
// actually open, then take the exact same path as a workspace drop:
Expand Down Expand Up @@ -9904,6 +9965,9 @@ export default function ChatView(props: ChatViewProps) {
onRevertToTurnCount={
paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn
}
{...(isServerThread && !paintOnlyDisplayedTimeline
? { onForkFromMessage: onForkTimelineMessage }
: {})}
isRevertingCheckpoint={!paintOnlyDisplayedTimeline && isRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment}
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import {
SquarePenIcon,
TerminalIcon,
Undo2Icon,
GitForkIcon,
WrenchIcon,
XIcon,
ZapIcon,
Expand Down Expand Up @@ -284,6 +285,7 @@ interface TimelineRowSharedState {
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
activeThreadEnvironmentId: EnvironmentId;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
onForkFromMessage: ((messageId: MessageId) => void) | null;
onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
onRunShellCommand: ((command: string) => void) | undefined;
onImageExpand: (preview: ExpandedImagePreview) => void;
Expand Down Expand Up @@ -433,6 +435,8 @@ interface MessagesTimelineProps {
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
supportsConversationRollback: boolean;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
/** Opens a new tab seeded from a user message; omitted where the chat cannot have tabs. */
onForkFromMessage?: (messageId: MessageId) => void;
onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
onRunShellCommand?: (command: string) => void;
isRevertingCheckpoint: boolean;
Expand Down Expand Up @@ -503,6 +507,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onOpenTurnDiff,
supportsConversationRollback,
onRevertToTurnCount,
onForkFromMessage,
onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
onRunShellCommand,
isRevertingCheckpoint,
Expand Down Expand Up @@ -1158,6 +1163,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
skills,
activeThreadEnvironmentId,
onRevertToTurnCount,
onForkFromMessage: onForkFromMessage ?? null,
onUseArtifactTemplate,
onRunShellCommand,
onImageExpand,
Expand Down Expand Up @@ -1194,6 +1200,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
skills,
activeThreadEnvironmentId,
onRevertToTurnCount,
onForkFromMessage,
onUseArtifactTemplate,
onRunShellCommand,
onImageExpand,
Expand Down Expand Up @@ -2242,6 +2249,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
{typeof revertTurnCount === "number" && (
<RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
)}
{ctx.onForkFromMessage && (
<ForkUserMessageButton messageId={row.message.id} onFork={ctx.onForkFromMessage} />
)}
{resolvedContext.text && (
<MessageCopyButton
// Structured paste needs the canonical links to retain their positions.
Expand Down Expand Up @@ -2321,6 +2331,33 @@ function RevertUserMessageButton({
);
}

function ForkUserMessageButton({
messageId,
onFork,
}: {
messageId: MessageId;
onFork: (messageId: MessageId) => void;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => onFork(messageId)}
aria-label="Fork into new tab"
/>
}
>
<GitForkIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">Fork into new tab</TooltipPopup>
</Tooltip>
);
}

/**
* Hover-revealed wall-clock time with a full-date tooltip — the same metadata
* presentation as message rows, for work entries and turn folds. The parent
Expand Down
Loading