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
136 changes: 135 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { findRecordedWorktreeSetup, mergeDirectPreviewAnnotation } from "./ChatView.logic";
import {
boundAttachmentIds,
findRecordedWorktreeSetup,
mergeDirectPreviewAnnotation,
planComposerTurn,
snapshotComposerSend,
withMessageContext,
} from "./ChatView.logic";
import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic";
import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import { deriveProviderInstanceEntries } from "../providerInstances";
import { ensureInlineContextReferences } from "../lib/composerContextReferences";
import { terminalContextReference } from "../lib/composerContextRecords";
import {
CommandId,
EnvironmentId,
Expand Down Expand Up @@ -1024,3 +1033,128 @@ describe("mergeDirectPreviewAnnotation", () => {
expect(result.previewAnnotations[0]?.screenshot).toBeNull();
});
});

describe("snapshotComposerSend", () => {
const expired = {
id: "ctx-expired",
threadId,
terminalId: "default",
terminalLabel: "Terminal 1",
lineStart: 4,
lineEnd: 4,
text: "",
createdAt: now,
};
const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 };

it("drops expired terminal contexts from the contexts and from the prompt", () => {
const snapshot = snapshotComposerSend({
prompt: ensureInlineContextReferences("run", [
terminalContextReference(expired),
terminalContextReference(live),
]),
images: [],
files: [],
terminalContexts: [expired, live],
sendableTerminalContexts: [live],
previewAnnotations: [],
reviewComments: [],
threadContexts: [],
});

expect(snapshot.terminalContexts).toEqual([live]);
expect(snapshot.messageText).toBe(
ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(),
);
});
});

describe("planComposerTurn", () => {
const base = {
messageText: "hello",
sendQueuesBehindSetup: false,
isServerThread: false,
activeMessageCount: 0,
sendEnvMode: "worktree" as const,
worktreePath: null,
activeThreadBranch: "main",
resumeCompactionTokens: null,
keepFullHistory: false,
phase: "ready" as const,
dispatchMode: "auto" as const,
};

it("prepares a worktree from the selected branch on a new thread", () => {
expect(planComposerTurn(base)).toMatchObject({
missingWorktreeBaseBranch: false,
baseBranchForWorktree: "main",
shouldQueueBehindActiveRun: false,
});
});

it("refuses a new worktree without a base branch", () => {
expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({
missingWorktreeBaseBranch: true,
baseBranchForWorktree: null,
});
});

it("queues behind a compaction of a stale session, except for /compact itself", () => {
expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({
compactBeforeSend: true,
turnDispatchMode: "queue",
shouldQueueBehindActiveRun: true,
});
expect(
planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }),
).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" });
});

it("queues behind a running turn only when asked to", () => {
const running = {
...base,
isServerThread: true,
activeMessageCount: 3,
phase: "running" as const,
};
expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe(
true,
);
expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({
shouldQueueBehindActiveRun: false,
baseBranchForWorktree: null,
});
});
});

describe("withMessageContext", () => {
const context = { records: [] } as unknown as NonNullable<
Parameters<typeof withMessageContext>[0]["context"]
>;

it("sends the context inline when the server supports it", () => {
expect(withMessageContext({ text: "hi", context, supportsInlineMessageContext: true })).toEqual(
{ text: "hi", context },
);
});

it("folds the context into the text for older servers", () => {
const result = withMessageContext({ text: "hi", context, supportsInlineMessageContext: false });
expect(result.context).toBeUndefined();
expect(result.text).toContain("hi");
});

it("sends the text alone without context", () => {
expect(
withMessageContext({ text: "hi", context: undefined, supportsInlineMessageContext: false }),
).toEqual({ text: "hi" });
});
});

describe("boundAttachmentIds", () => {
it("prefers the uploaded id and falls back to the local one", () => {
expect(
boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]),
).toEqual(["upload-1", "local-2"]);
});
});
172 changes: 170 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ProjectId,
type MessageId,
type ModelSelection,
type OrchestrationMessageContext,
type OrchestrationV2ProjectedTurnItem,
type PreviewAnnotationPayload,
type ProviderInteractionMode,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
type TurnDiffSummary,
} from "../types";
import {
type ComposerFileAttachment,
type ComposerImageAttachment,
type DraftId,
type DraftThreadState,
Expand All @@ -61,8 +63,22 @@ import * as Schema from "effect/Schema";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadShells, environmentThreadDetails } from "../state/threads";
import { waitForAtomValue } from "../state/waitForAtomValue";
import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext";
import { stripInlineContextReferences } from "~/lib/composerContextReferences";
import {
filterTerminalContextsWithText,
formatTerminalContextLabel,
type TerminalContextDraft,
} from "../lib/terminalContext";
import {
removeInlineContextReference,
stripInlineContextReferences,
} from "~/lib/composerContextReferences";
import {
previewAnnotationContextLabel,
reviewCommentContextLabel,
terminalContextReference,
} from "../lib/composerContextRecords";
import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch";
import type { DraftThreadEnvMode } from "../composerDraftStore";
import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic";
import type { ReviewCommentContext } from "../reviewCommentContext";
Expand Down Expand Up @@ -1442,3 +1458,155 @@ export function makeModelDisabledReason(
return reason ? `${reason.description} Start a new thread to use this model.` : null;
};
}

/** What the composer held when the user pressed send, after a direct annotation merged in. */
export interface ComposerSendContents {
readonly prompt: string;
readonly images: ReadonlyArray<ComposerImageAttachment>;
readonly files: ReadonlyArray<ComposerFileAttachment>;
readonly terminalContexts: ReadonlyArray<TerminalContextDraft>;
readonly sendableTerminalContexts: ReadonlyArray<TerminalContextDraft>;
readonly previewAnnotations: ReadonlyArray<PreviewAnnotationPayload>;
readonly reviewComments: ReadonlyArray<ReviewCommentContext>;
readonly threadContexts: ReadonlyArray<ThreadContextRecord>;
}

/**
* Copies the composer contents a send dispatches, so a failed send can put the
* same draft back. Terminal contexts that lost their text are dropped from both
* the contexts and the prompt's inline references.
*/
export function snapshotComposerSend(contents: ComposerSendContents) {
const images = [...contents.images];
const files = [...contents.files];
const terminalContexts = [...contents.sendableTerminalContexts];
return {
images,
files,
attachments: [...images, ...files],
terminalContexts,
previewAnnotations: [...contents.previewAnnotations],
reviewComments: [...contents.reviewComments],
threadContexts: [...contents.threadContexts],
messageText: contents.terminalContexts
.filter((context) => !terminalContexts.includes(context))
.reduce(
(text, context) =>
removeInlineContextReference(text, terminalContextReference(context).contextId).prompt,
contents.prompt,
)
.trim(),
};
}

export type ComposerSendSnapshot = ReturnType<typeof snapshotComposerSend>;

/** How a composer send dispatches: where it runs and whether it waits behind other work. */
export function planComposerTurn(input: {
readonly messageText: string;
readonly sendQueuesBehindSetup: boolean;
readonly isServerThread: boolean;
readonly activeMessageCount: number;
readonly sendEnvMode: DraftThreadEnvMode;
readonly worktreePath: string | null;
readonly activeThreadBranch: string | null;
readonly resumeCompactionTokens: number | null;
readonly keepFullHistory: boolean;
readonly phase: SessionPhase;
readonly dispatchMode: ComposerDispatchMode;
}) {
const isFirstMessage =
!input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0);
const shouldCreateWorktree =
isFirstMessage && input.sendEnvMode === "worktree" && !input.worktreePath;
// A stale Claude session compacts before this message so the turn does not re-read the
// old history. The message queues behind the /compact run, since steering into it is
// rejected.
const compactBeforeSend =
input.resumeCompactionTokens !== null &&
!input.keepFullHistory &&
input.messageText.toLowerCase() !== "/compact";
return {
// In worktree mode, require an explicit base branch so we don't silently
// fall back to local execution when branch selection is missing.
missingWorktreeBaseBranch: shouldCreateWorktree && !input.activeThreadBranch,
baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null,
compactBeforeSend,
turnDispatchMode:
compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode,
shouldQueueBehindActiveRun:
compactBeforeSend ||
input.sendQueuesBehindSetup ||
(input.phase === "running" && input.dispatchMode === "queue"),
};
}

/**
* The attachment ids a message's context records bind to: the upload id the
* server returned, or the local id on the data-URL path.
*/
export function boundAttachmentIds(
turnAttachments: ReadonlyArray<{ readonly id?: string | undefined }>,
attachments: ReadonlyArray<{ readonly id: string }>,
): string[] {
return turnAttachments.map((attachment, index) =>
"id" in attachment && attachment.id !== undefined ? attachment.id : attachments[index]!.id,
);
}

/**
* The message text and context a turn sends. Servers from before inline
* context drop the records and forward the links as literal text, so their
* turns carry the payload the legacy way.
*/
export function withMessageContext(input: {
readonly text: string;
readonly context: OrchestrationMessageContext | undefined;
readonly supportsInlineMessageContext: boolean;
}): { readonly text: string; readonly context?: OrchestrationMessageContext } {
const { text, context } = input;
if (context === undefined) return { text };
if (!input.supportsInlineMessageContext) {
return { text: serializeLegacyContextMessage({ text, records: context.records }) };
}
return { text, context };
}

/** The attachments the optimistic user row shows before the server echoes the message. */
export function optimisticMessageAttachments(
attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>,
) {
return attachments.map((attachment) =>
attachment.type === "image"
? {
type: "image" as const,
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
previewUrl: attachment.previewUrl,
...(attachment.source ? { source: attachment.source } : {}),
}
: {
type: "file" as const,
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
downloadable: false,
...(attachment.source ? { source: attachment.source } : {}),
},
);
}

/** Fallback title sources for a message with no text of its own. */
export function sendTitleFallbackLabels(snapshot: ComposerSendSnapshot) {
const terminalContext = snapshot.terminalContexts[0];
const reviewComment = snapshot.reviewComments[0];
const previewAnnotation = snapshot.previewAnnotations[0];
return [
terminalContext ? formatTerminalContextLabel(terminalContext) : undefined,
reviewComment ? `Review: ${reviewCommentContextLabel(reviewComment)}` : undefined,
previewAnnotation ? previewAnnotationContextLabel(previewAnnotation) : undefined,
];
}
Loading
Loading