import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";import { type ChatMessage, type Thread } from "../types";import { randomUUID } from "~/lib/utils";import { getAppModelOptions } from "../appSettings";import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";import { Schema } from "effect";import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft,} from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModel: string, error: string | null,): Thread { return { id: threadId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", model: fallbackModel, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, session: null, messages: [], error, createdAt: draftThread.createdAt, latestTurn: null, lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, worktreePath: draftThread.worktreePath, turnDiffSummaries: [], activities: [], proposedPlans: [], };} export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl);} export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; } for (const attachment of message.attachments) { if (attachment.type !== "image") { continue; } revokeBlobPreviewUrl(attachment.previewUrl); }} export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; } const previewUrls: string[] = []; for (const attachment of message.attachments) { if (attachment.type !== "image") continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } return previewUrls;} export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; export interface PullRequestDialogState { initialReference: string | null; key: number;} export function readFileAsDataUrl(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Could not read image data.")); }); reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read image.")); }); reader.readAsDataURL(file); });} export function buildTemporaryWorktreeBranchName(): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = randomUUID().slice(0, 8).toLowerCase(); return `${WORKTREE_BRANCH_PREFIX}/${token}`;} export function cloneComposerImageForRetry( image: ComposerImageAttachment,): ComposerImageAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } try { return { ...image, previewUrl: URL.createObjectURL(image.file), }; } catch { return image; }} export function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; customClaudeModels: readonly string[];}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { return { codex: getAppModelOptions("codex", settings.customCodexModels), claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), };} export function deriveComposerSendState(options: { prompt: string; imageCount: number; terminalContexts: ReadonlyArray<TerminalContextDraft>;}): { trimmedPrompt: string; sendableTerminalContexts: TerminalContextDraft[]; expiredTerminalContextCount: number; hasSendableContent: boolean;} { const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); const expiredTerminalContextCount = options.terminalContexts.length - sendableTerminalContexts.length; return { trimmedPrompt, sendableTerminalContexts, expiredTerminalContextCount, hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, };} export function buildExpiredTerminalContextToastCopy( expiredTerminalContextCount: number, variant: "omitted" | "empty",): { title: string; description: string } { const count = Math.max(1, Math.floor(expiredTerminalContextCount)); const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; if (variant === "empty") { return { title: `${noun} won't be sent`, description: "Remove it or re-add it to include terminal output.", }; } return { title: `${noun} omitted from message`, description: "Re-add it if you want that terminal output included.", };}
import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";
import { type ChatMessage, type Thread } from "../types";
import { randomUUID } from "~/lib/utils";
import { getAppModelOptions } from "../appSettings";
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
import { Schema } from "effect";
import {
filterTerminalContextsWithText,
stripInlineTerminalContextPlaceholders,
type TerminalContextDraft,
} from "../lib/terminalContext";
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
const WORKTREE_BRANCH_PREFIX = "t3code";
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
export function buildLocalDraftThread(
threadId: ThreadId,
draftThread: DraftThreadState,
fallbackModel: string,
error: string | null,
): Thread {
return {
id: threadId,
codexThreadId: null,
projectId: draftThread.projectId,
title: "New thread",
model: fallbackModel,
runtimeMode: draftThread.runtimeMode,
interactionMode: draftThread.interactionMode,
session: null,
messages: [],
error,
createdAt: draftThread.createdAt,
latestTurn: null,
lastVisitedAt: draftThread.createdAt,
branch: draftThread.branch,
worktreePath: draftThread.worktreePath,
turnDiffSummaries: [],
activities: [],
proposedPlans: [],
};
}
export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
return;
}
URL.revokeObjectURL(previewUrl);
}
export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
if (message.role !== "user" || !message.attachments) {
return;
}
for (const attachment of message.attachments) {
if (attachment.type !== "image") {
continue;
}
revokeBlobPreviewUrl(attachment.previewUrl);
}
}
export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
if (message.role !== "user" || !message.attachments) {
return [];
}
const previewUrls: string[] = [];
for (const attachment of message.attachments) {
if (attachment.type !== "image") continue;
if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;
previewUrls.push(attachment.previewUrl);
}
return previewUrls;
}
export type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
export interface PullRequestDialogState {
initialReference: string | null;
key: number;
}
export function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Could not read image data."));
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read image."));
});
reader.readAsDataURL(file);
});
}
export function buildTemporaryWorktreeBranchName(): string {
// Keep the 8-hex suffix shape for backend temporary-branch detection.
const token = randomUUID().slice(0, 8).toLowerCase();
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
}
export function cloneComposerImageForRetry(
image: ComposerImageAttachment,
): ComposerImageAttachment {
if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) {
return image;
}
try {
return {
...image,
previewUrl: URL.createObjectURL(image.file),
};
} catch {
return image;
}
}
export function getCustomModelOptionsByProvider(settings: {
customCodexModels: readonly string[];
customClaudeModels: readonly string[];
}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
return {
codex: getAppModelOptions("codex", settings.customCodexModels),
claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels),
};
}
export function deriveComposerSendState(options: {
prompt: string;
imageCount: number;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
}): {
trimmedPrompt: string;
sendableTerminalContexts: TerminalContextDraft[];
expiredTerminalContextCount: number;
hasSendableContent: boolean;
} {
const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim();
const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
const expiredTerminalContextCount =
options.terminalContexts.length - sendableTerminalContexts.length;
return {
trimmedPrompt,
sendableTerminalContexts,
expiredTerminalContextCount,
hasSendableContent:
trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0,
};
}
export function buildExpiredTerminalContextToastCopy(
expiredTerminalContextCount: number,
variant: "omitted" | "empty",
): { title: string; description: string } {
const count = Math.max(1, Math.floor(expiredTerminalContextCount));
const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts";
if (variant === "empty") {
return {
title: `${noun} won't be sent`,
description: "Remove it or re-add it to include terminal output.",
};
}
return {
title: `${noun} omitted from message`,
description: "Re-add it if you want that terminal output included.",
};
}