diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 88ea30cdc2cd2..15f2bc680f9b2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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, diff --git a/apps/web/src/components/chat/composerSend.logic.test.ts b/apps/web/src/components/chat/composerSend.logic.test.ts new file mode 100644 index 0000000000000..062fbc22eb66e --- /dev/null +++ b/apps/web/src/components/chat/composerSend.logic.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/components/chat/composerSend.logic.ts b/apps/web/src/components/chat/composerSend.logic.ts new file mode 100644 index 0000000000000..e19512e6c90d4 --- /dev/null +++ b/apps/web/src/components/chat/composerSend.logic.ts @@ -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; +}): ReadonlyArray { + 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; + readonly candidateEnvironmentIds: ReadonlyArray; +}) { + 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; +} diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 3e9ec35ca1f25..d3bcbdf470203 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -82,6 +82,14 @@ import { import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { runMobileComposerTransition } from "./draftHeroTransition"; +import { useClientSettingsHydrated, useEnvironmentSettings } from "../../hooks/useSettings"; +import { environmentPresentations } from "../../state/presentation"; +import { + approvalRedirectNotice, + loadBalancingSendWarning, + multipleModelSendBlock, + referencedThreadContexts, +} from "./composerSend.logic"; import { usePlanFollowUpSend } from "./useProposedPlanSend"; import { useMultipleModelSend } from "./useMultipleModelSend"; import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -108,12 +116,11 @@ import { prepareTurnAttachments, restoreComposerDraft, } from "./chatViewSend"; -import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import type { ThreadContextRecord, UnifiedSettings } from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts"; import type { Dispatch } from "react"; import type { ComposerThreadTarget } from "../../composerDraftStore"; import type { ProviderInstanceEntry } from "../../providerInstances"; @@ -122,7 +129,6 @@ import type { DraftSessionState } from "../../composerDraftStore"; import type { ComposerHandleRef } from "../../composerHandleContext"; import type { useLogicalProjectEnvironments } from "../../hooks/useLogicalProjectEnvironments"; import type { LatestProposedPlanState } from "../../session-logic"; -import type { EnvironmentPresentation } from "../../state/environments"; import type { useDraftEnvironmentRouting } from "./useDraftEnvironmentRouting"; import type { useLocalDispatchState } from "./useLocalDispatchState"; import type { usePendingThreadRequests } from "./usePendingThreadRequests"; @@ -133,6 +139,17 @@ import type { useWorktreeSetup } from "./useWorktreeSetup"; const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; +const selectPlanModeEnabled = (settings: UnifiedSettings) => settings.planModeEnabled; + +function readEnvironmentPresentation(environmentId: EnvironmentId) { + return appAtomRegistry.get(environmentPresentations.presentationsAtom).get(environmentId); +} + +function draftHasNonPromptContent(target: ComposerThreadTarget) { + const draft = useComposerDraftStore.getState().getComposerDraft(target); + return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; +} + type PendingThreadRequests = ReturnType; type LocalDispatchState = ReturnType; type DraftEnvironmentRouting = ReturnType; @@ -163,15 +180,8 @@ interface ComposerSendInput { readonly threadBranch: string; readonly currentBranch: string; } | null; - readonly settings: UnifiedSettings; - readonly clientSettingsHydrated: boolean; - readonly serverConfig: EnvironmentServerConfig | null; - readonly environmentById: ReadonlyMap; readonly logicalProjectEnvironments: ReturnType; readonly providerInstanceEntries: ReadonlyArray; - readonly supportsAttachmentUploads: boolean; - readonly referencedThreadContexts: ReadonlyArray; - readonly composerHasNonPromptContent: boolean; readonly composerRef: ComposerHandleRef; readonly composerDraftTarget: ComposerThreadTarget; readonly promptRef: { current: string }; @@ -257,15 +267,8 @@ export function useComposerSend(input: ComposerSendInput) { sendEnvMode, startFromOrigin, localCheckoutBranchMismatch, - settings, - clientSettingsHydrated, - serverConfig, - environmentById, logicalProjectEnvironments, providerInstanceEntries, - supportsAttachmentUploads, - referencedThreadContexts, - composerHasNonPromptContent, composerRef, composerDraftTarget, promptRef, @@ -316,6 +319,8 @@ export function useComposerSend(input: ComposerSendInput) { } = input; const handleNewThread = useNewThreadHandler(); const navigate = useNavigate(); + const clientSettingsHydrated = useClientSettingsHydrated(); + const planModeEnabled = useEnvironmentSettings(environmentId, selectPlanModeEnabled); const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); @@ -392,7 +397,7 @@ export function useComposerSend(input: ComposerSendInput) { usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && - !composerHasNonPromptContent && + !draftHasNonPromptContent(composerDraftTarget) && isUsageLimitsCommand(promptRef.current) ) { if (openUsageLimits()) { @@ -429,21 +434,16 @@ export function useComposerSend(input: ComposerSendInput) { return; } if (needsLoadBalancing) { + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); toastManager.add({ type: "warning", - title: loadBalancing.pending - ? "Checking machine resources" - : "Choose a machine to continue", - description: loadBalancing.pending - ? "Resource checks are still running. You can choose a machine in the composer." - : referencedThreadContexts.length > 0 && - !logicalProjectEnvironments.some((candidate) => - referencedThreadContexts.every( - (record) => record.environmentId === candidate.environmentId, - ), - ) - ? "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.", + ...loadBalancingSendWarning({ + pending: loadBalancing.pending, + threadContexts: draft ? referencedThreadContexts(draft) : [], + candidateEnvironmentIds: logicalProjectEnvironments.map( + (candidate) => candidate.environmentId, + ), + }), }); return; } @@ -475,20 +475,23 @@ export function useComposerSend(input: ComposerSendInput) { return; } const multipleModelSelections = sendCtx.multipleModelSelections; - if ( - multipleModelSelections !== null && - serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true - ) { + const multipleModelBlock = + multipleModelSelections === null + ? null + : multipleModelSendBlock({ + selectionCount: multipleModelSelections.length, + serverSupportsWorktreeBootstrap: + readEnvironmentPresentation(activeThread.environmentId)?.serverConfig?.environment + .capabilities.requiredWorktreeBootstrap === true, + isLocalDraftThread, + isGitRepo, + branch: activeThreadBranch, + }); + if (multipleModelBlock === "server-outdated") { setThreadError(activeThread.id, "Update this server before starting multiple models."); return; } - if ( - multipleModelSelections !== null && - (!isLocalDraftThread || - !isGitRepo || - !activeThreadBranch || - multipleModelSelections.length === 0) - ) { + if (multipleModelBlock === "needs-new-git-thread") { toastManager.add( stackedThreadToast({ type: "warning", @@ -534,7 +537,9 @@ export function useComposerSend(input: ComposerSendInput) { environmentId, })[0]; if (strandedThread) { - const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine"; + const owner = + readEnvironmentPresentation(strandedThread.environmentId)?.entry.target.label ?? + "its machine"; toastManager.add( stackedThreadToast({ type: "warning", @@ -838,7 +843,7 @@ export function useComposerSend(input: ComposerSendInput) { selections: multipleModelSelections ?? [], providerInstanceEntries, messageText: messageTextForSend, - planModeEnabled: settings.planModeEnabled, + planModeEnabled, interactionMode: sendInteractionMode, })) { if ("error" in resolved) { @@ -860,7 +865,8 @@ export function useComposerSend(input: ComposerSendInput) { const turnUsesAttachmentUploads = composerFilesSnapshot.length > 0 ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads - : supportsAttachmentUploads; + : readEnvironmentPresentation(environmentId)?.serverConfig?.environment.capabilities + .attachmentUploads === true; if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) { for (const attachment of composerAttachmentsSnapshot) { const upload = @@ -1001,16 +1007,16 @@ export function useComposerSend(input: ComposerSendInput) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); - if (draftChanged || approvalResponding || (declined && approvalArrived)) { + const notice = approvalRedirectNotice({ + draftChanged, + declined, + approvalResponding, + approvalArrived, + }); + if (notice !== null) { toastManager.add({ type: "info", - title: draftChanged - ? declined - ? "Approval declined. Your message changed, so it wasn't sent." - : "Your message changed, so it wasn't sent." - : approvalResponding - ? "An approval is already being answered, so your message wasn't sent." - : "Another approval arrived, so your message wasn't sent.", + title: notice, description: "Send again when it's ready.", }); }