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
7 changes: 0 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2983,15 +2983,8 @@ export default function ChatView(props: ChatViewProps) {
sendEnvMode,
startFromOrigin,
localCheckoutBranchMismatch,
settings,
clientSettingsHydrated,
serverConfig,
environmentById,
logicalProjectEnvironments,
providerInstanceEntries,
supportsAttachmentUploads,
referencedThreadContexts,
composerHasNonPromptContent,
composerRef,
composerDraftTarget,
promptRef,
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/chat/composerSend.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { describe, expect, it } from "vite-plus/test";
import { formatInlineContextReference } from "../../lib/composerContextReferences";
import { threadContextRecord, threadContextReference } from "../../lib/composerContextRecords";
import {
approvalRedirectNotice,
loadBalancingSendWarning,
multipleModelSendBlock,
referencedThreadContexts,
} from "./composerSend.logic";

const local = EnvironmentId.make("local");
const remote = EnvironmentId.make("remote");
const localThread = threadContextRecord(scopeThreadRef(local, ThreadId.make("a")), "Local");
const remoteThread = threadContextRecord(scopeThreadRef(remote, ThreadId.make("b")), "Remote");

describe("referencedThreadContexts", () => {
it("keeps only the attached threads the prompt still links", () => {
const prompt = `see ${formatInlineContextReference(threadContextReference(remoteThread))}`;
expect(
referencedThreadContexts({ prompt, threadContexts: [localThread, remoteThread] }),
).toEqual([remoteThread]);
});
});

describe("loadBalancingSendWarning", () => {
it("asks the user to wait while resource checks run", () => {
expect(
loadBalancingSendWarning({
pending: true,
threadContexts: [localThread, remoteThread],
candidateEnvironmentIds: [],
}).title,
).toBe("Checking machine resources");
});

it("blames the attached threads when no machine can read all of them", () => {
expect(
loadBalancingSendWarning({
pending: false,
threadContexts: [localThread, remoteThread],
candidateEnvironmentIds: [local, remote],
}).description,
).toMatch(/No machine can read every attached thread/);
});

it("blames resources when one machine can read every attached thread", () => {
expect(
loadBalancingSendWarning({
pending: false,
threadContexts: [localThread],
candidateEnvironmentIds: [remote, local],
}).description,
).toMatch(/No eligible machine has available resources/);
});
});

describe("multipleModelSendBlock", () => {
const ready = {
selectionCount: 2,
serverSupportsWorktreeBootstrap: true,
isLocalDraftThread: true,
isGitRepo: true,
branch: "main",
};

it("lets a new Git draft with a branch and models start", () => {
expect(multipleModelSendBlock(ready)).toBeNull();
});

it("asks for a server update before checking the draft", () => {
expect(
multipleModelSendBlock({
...ready,
serverSupportsWorktreeBootstrap: false,
isGitRepo: false,
}),
).toBe("server-outdated");
});

it.each([
{ isLocalDraftThread: false },
{ isGitRepo: false },
{ branch: null },
{ selectionCount: 0 },
])("needs a new Git thread with a branch and models (%o)", (override) => {
expect(multipleModelSendBlock({ ...ready, ...override })).toBe("needs-new-git-thread");
});
});

describe("approvalRedirectNotice", () => {
const settled = {
draftChanged: false,
declined: true,
approvalResponding: false,
approvalArrived: false,
};

it("says the decline went through when the draft changed after it", () => {
expect(approvalRedirectNotice({ ...settled, draftChanged: true })).toBe(
"Approval declined. Your message changed, so it wasn't sent.",
);
expect(approvalRedirectNotice({ ...settled, draftChanged: true, declined: false })).toBe(
"Your message changed, so it wasn't sent.",
);
});

it("explains an approval already being answered", () => {
expect(
approvalRedirectNotice({ ...settled, declined: false, approvalResponding: true }),
).toMatch(/already being answered/);
});

it("explains an approval that arrived during the decline", () => {
expect(approvalRedirectNotice({ ...settled, approvalArrived: true })).toMatch(
/Another approval arrived/,
);
});

it("stays quiet when the decline failed or the user left the thread", () => {
expect(approvalRedirectNotice({ ...settled, declined: false })).toBeNull();
expect(approvalRedirectNotice(settled)).toBeNull();
});
});
81 changes: 81 additions & 0 deletions apps/web/src/components/chat/composerSend.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts";
import { collectInlineContextIds } from "../../lib/composerContextReferences";

/** Attached threads the prompt still links to; replacing linked text leaves stale records. */
export function referencedThreadContexts(draft: {
readonly prompt: string;
readonly threadContexts: ReadonlyArray<ThreadContextRecord>;
}): ReadonlyArray<ThreadContextRecord> {
if (draft.threadContexts.length === 0) return [];
const referenced = new Set(collectInlineContextIds(draft.prompt));
return draft.threadContexts.filter((record) => referenced.has(record.contextId));
}

/** Why a send waits for the user to pick a machine when no machine was chosen automatically. */
export function loadBalancingSendWarning(input: {
readonly pending: boolean;
readonly threadContexts: ReadonlyArray<ThreadContextRecord>;
readonly candidateEnvironmentIds: ReadonlyArray<EnvironmentId>;
}) {
if (input.pending) {
return {
title: "Checking machine resources",
description: "Resource checks are still running. You can choose a machine in the composer.",
};
}
const noMachineReadsEveryThread =
input.threadContexts.length > 0 &&
!input.candidateEnvironmentIds.some((environmentId) =>
input.threadContexts.every((record) => record.environmentId === environmentId),
);
return {
title: "Choose a machine to continue",
description: noMachineReadsEveryThread
? "No machine can read every attached thread. Choose a machine in the composer or remove the threads."
: "No eligible machine has available resources. Choose a machine in the composer to override.",
};
}

/**
* Why a multiple-model send cannot start: the server predates worktree bootstrap, or the
* draft is not a new thread on a Git branch with at least one model.
*/
export function multipleModelSendBlock(input: {
readonly selectionCount: number;
readonly serverSupportsWorktreeBootstrap: boolean;
readonly isLocalDraftThread: boolean;
readonly isGitRepo: boolean;
readonly branch: string | null;
}): "server-outdated" | "needs-new-git-thread" | null {
if (!input.serverSupportsWorktreeBootstrap) return "server-outdated";
if (
!input.isLocalDraftThread ||
!input.isGitRepo ||
!input.branch ||
input.selectionCount === 0
) {
return "needs-new-git-thread";
}
return null;
}

/** Why a message sent over pending approvals did not go out, or null to stop quietly. */
export function approvalRedirectNotice(input: {
readonly draftChanged: boolean;
readonly declined: boolean;
readonly approvalResponding: boolean;
readonly approvalArrived: boolean;
}): string | null {
if (input.draftChanged) {
return input.declined
? "Approval declined. Your message changed, so it wasn't sent."
: "Your message changed, so it wasn't sent.";
}
if (input.approvalResponding) {
return "An approval is already being answered, so your message wasn't sent.";
}
if (input.declined && input.approvalArrived) {
return "Another approval arrived, so your message wasn't sent.";
}
return null;
}
Loading
Loading