# Files changed · PR \#23095 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23095/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[refactor\(web\): the composer send reads its own settings and draft instead of taking them from ChatView](https://git.cafe/pingdotgg/t3code/pull/23095.md)

Version: 6

Head commit: f6f27d6ba1e15350293c4be0ad3f3ae8996acc61

Base commit: 70d001c0c38d5e2eaf823da1e55d66b35777684b

Comparison base: 70d001c0c38d5e2eaf823da1e55d66b35777684b

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/ChatView.tsx

Status: modified; +0; −7

```diff
@@ -2983,15 +2983,8 @@
     sendEnvMode,
     startFromOrigin,
     localCheckoutBranchMismatch,
-    settings,
-    clientSettingsHydrated,
-    serverConfig,
-    environmentById,
     logicalProjectEnvironments,
     providerInstanceEntries,
-    supportsAttachmentUploads,
-    referencedThreadContexts,
-    composerHasNonPromptContent,
     composerRef,
     composerDraftTarget,
     promptRef,
```

## apps/web/src/components/chat/composerSend.logic.test.ts

Status: added; +125; −0

```diff
@@ -1,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();
+  });
+});
```

## apps/web/src/components/chat/composerSend.logic.ts

Status: added; +81; −0

```diff
@@ -1,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;
+}
```

## apps/web/src/components/chat/useComposerSend.ts

Status: modified; +59; −53

```diff
@@ -82,6 +82,14 @@
 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 @@
   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 { 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";
@@ -132,7 +138,18 @@
 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<typeof usePendingThreadRequests>;
 type LocalDispatchState = ReturnType<typeof useLocalDispatchState>;
 type DraftEnvironmentRouting = ReturnType<typeof useDraftEnvironmentRouting>;
@@ -163,15 +180,8 @@
     readonly threadBranch: string;
     readonly currentBranch: string;
   } | null;
-  readonly settings: UnifiedSettings;
-  readonly clientSettingsHydrated: boolean;
-  readonly serverConfig: EnvironmentServerConfig | null;
-  readonly environmentById: ReadonlyMap<EnvironmentId, EnvironmentPresentation>;
   readonly logicalProjectEnvironments: ReturnType<typeof useLogicalProjectEnvironments>;
   readonly providerInstanceEntries: ReadonlyArray<ProviderInstanceEntry>;
-  readonly supportsAttachmentUploads: boolean;
-  readonly referencedThreadContexts: ReadonlyArray<ThreadContextRecord>;
-  readonly composerHasNonPromptContent: boolean;
   readonly composerRef: ComposerHandleRef;
   readonly composerDraftTarget: ComposerThreadTarget;
   readonly promptRef: { current: string };
@@ -257,15 +267,8 @@
     sendEnvMode,
     startFromOrigin,
     localCheckoutBranchMismatch,
-    settings,
-    clientSettingsHydrated,
-    serverConfig,
-    environmentById,
     logicalProjectEnvironments,
     providerInstanceEntries,
-    supportsAttachmentUploads,
-    referencedThreadContexts,
-    composerHasNonPromptContent,
     composerRef,
     composerDraftTarget,
     promptRef,
@@ -316,6 +319,8 @@
   } = 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 @@
       usageLimitsOffered &&
       usageLimitsKey !== null &&
       !directAnnotation &&
-      !composerHasNonPromptContent &&
+      !draftHasNonPromptContent(composerDraftTarget) &&
       isUsageLimitsCommand(promptRef.current)
     ) {
       if (openUsageLimits()) {
@@ -429,21 +434,16 @@
       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,
-                ),
+        ...loadBalancingSendWarning({
-              )
-            ? "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.",
+          pending: loadBalancing.pending,
+          threadContexts: draft ? referencedThreadContexts(draft) : [],
+          candidateEnvironmentIds: logicalProjectEnvironments.map(
+            (candidate) => candidate.environmentId,
+          ),
+        }),
       });
       return;
     }
@@ -475,20 +475,23 @@
       return;
     }
     const multipleModelSelections = sendCtx.multipleModelSelections;
-    if (
+    const multipleModelBlock =
+      multipleModelSelections === null
+        ? null
+        : multipleModelSendBlock({
-      multipleModelSelections !== null &&
-      serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true
-    ) {
+            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 @@
       environmentId,
     })[0];
     if (strandedThread) {
+      const owner =
+        readEnvironmentPresentation(strandedThread.environmentId)?.entry.target.label ??
+        "its machine";
-      const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine";
       toastManager.add(
         stackedThreadToast({
           type: "warning",
@@ -838,7 +843,7 @@
       selections: multipleModelSelections ?? [],
       providerInstanceEntries,
       messageText: messageTextForSend,
-      planModeEnabled: settings.planModeEnabled,
+      planModeEnabled,
       interactionMode: sendInteractionMode,
     })) {
       if ("error" in resolved) {
@@ -860,7 +865,8 @@
     const turnUsesAttachmentUploads =
       composerFilesSnapshot.length > 0
         ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads
+        : readEnvironmentPresentation(environmentId)?.serverConfig?.environment.capabilities
+            .attachmentUploads === true;
-        : supportsAttachmentUploads;
     if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) {
       for (const attachment of composerAttachmentsSnapshot) {
         const upload =
@@ -1001,16 +1007,16 @@
         setDockedDraftHeroThreadKey((currentThreadKey) =>
           currentThreadKey === activeThreadKey ? null : currentThreadKey,
         );
+        const notice = approvalRedirectNotice({
-        if (draftChanged || approvalResponding || (declined && approvalArrived)) {
+          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."
+            title: notice,
-              : approvalResponding
-                ? "An approval is already being answered, so your message wasn't sent."
-                : "Another approval arrived, so your message wasn't sent.",
             description: "Send again when it's ready.",
           });
         }
```
