| author | |
| committer | |
| log | 7fbc545ae8c7866ac2b39648120cb2a17250d8b4 |
| tree | 58cde99d2bf9390466652256d95161a0996df132 |
| parent | de545c41737aca08359244b7778ab52e55934f17 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>11 files changed, 263 insertions(+), 11 deletions(-)
apps/server/src/provider/Layers/ClaudeProvider.ts+1| ... | @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili | ... | @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili |
| 55 | const CLAUDE_PRESENTATION = { | 55 | const CLAUDE_PRESENTATION = { |
| 56 | displayName: "Claude", | 56 | displayName: "Claude", |
| 57 | showInteractionModeToggle: true, | 57 | showInteractionModeToggle: true, |
| 58 | reportsContextWindow: true, | ||
| 58 | } as const; | 59 | } as const; |
| 59 | function toTitleCaseWords(value: string): string { | 60 | function toTitleCaseWords(value: string): string { |
| 60 | const parts: Array<string> = []; | 61 | const parts: Array<string> = []; |
apps/server/src/provider/Layers/CodexProvider.ts+1| ... | @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | ... | @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; |
| 62 | const CODEX_PRESENTATION = { | 62 | const CODEX_PRESENTATION = { |
| 63 | displayName: "Codex", | 63 | displayName: "Codex", |
| 64 | showInteractionModeToggle: true, | 64 | showInteractionModeToggle: true, |
| 65 | reportsContextWindow: true, | ||
| 65 | } as const; | 66 | } as const; |
| 66 | 67 | ||
| 67 | export interface CodexAppServerProviderSnapshot { | 68 | export interface CodexAppServerProviderSnapshot { |
apps/server/src/provider/providerSnapshot.ts+4| ... | @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { | ... | @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { |
| 64 | readonly displayName: string; | 64 | readonly displayName: string; |
| 65 | readonly badgeLabel?: string; | 65 | readonly badgeLabel?: string; |
| 66 | readonly showInteractionModeToggle?: boolean; | 66 | readonly showInteractionModeToggle?: boolean; |
| 67 | readonly reportsContextWindow?: boolean; | ||
| 67 | readonly requiresNewThreadForModelChange?: boolean; | 68 | readonly requiresNewThreadForModelChange?: boolean; |
| 68 | } | 69 | } |
| 69 | 70 | ||
| ... | @@ -212,6 +213,9 @@ export function buildServerProvider(input: { | ... | @@ -212,6 +213,9 @@ export function buildServerProvider(input: { |
| 212 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" | 213 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" |
| 213 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } | 214 | ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } |
| 214 | : {}), | 215 | : {}), |
| 216 | ...(typeof input.presentation.reportsContextWindow === "boolean" | ||
| 217 | ? { reportsContextWindow: input.presentation.reportsContextWindow } | ||
| 218 | : {}), | ||
| 215 | ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" | 219 | ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" |
| 216 | ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } | 220 | ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } |
| 217 | : {}), | 221 | : {}), |
apps/web/src/components/ChatView.logic.test.ts+62| ... | @@ -47,6 +47,8 @@ import { | ... | @@ -47,6 +47,8 @@ import { |
| 47 | isBranchMismatchDismissedForSession, | 47 | isBranchMismatchDismissedForSession, |
| 48 | reconcileMountedTerminalThreadIds, | 48 | reconcileMountedTerminalThreadIds, |
| 49 | reconcileRetainedMountedThreadIds, | 49 | reconcileRetainedMountedThreadIds, |
| 50 | recallCheckoutIsRepo, | ||
| 51 | rememberCheckoutIsRepo, | ||
| 50 | resolveBackgroundDraftWorkspaceOptions, | 52 | resolveBackgroundDraftWorkspaceOptions, |
| 51 | resolveComposerInteractionMode, | 53 | resolveComposerInteractionMode, |
| 52 | resolveComposerProviderSelection, | 54 | resolveComposerProviderSelection, |
| ... | @@ -55,6 +57,7 @@ import { | ... | @@ -55,6 +57,7 @@ import { |
| 55 | resolveProactiveTurnDiffAction, | 57 | resolveProactiveTurnDiffAction, |
| 56 | resolveThreadMetadataUpdateForNextTurn, | 58 | resolveThreadMetadataUpdateForNextTurn, |
| 57 | resolveSendEnvMode, | 59 | resolveSendEnvMode, |
| 60 | threadShellHasStarted, | ||
| 58 | resolveDraftHeroState, | 61 | resolveDraftHeroState, |
| 59 | scheduleEnvironmentReconnectWarning, | 62 | scheduleEnvironmentReconnectWarning, |
| 60 | startNewThreadForProject, | 63 | startNewThreadForProject, |
| ... | @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { | ... | @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { |
| 1958 | expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); | 1961 | expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); |
| 1959 | }); | 1962 | }); |
| 1960 | }); | 1963 | }); |
| 1964 | |||
| 1965 | describe("checkout Git memory", () => { | ||
| 1966 | it("answers from the last status seen for the same checkout", () => { | ||
| 1967 | rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); | ||
| 1968 | expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); | ||
| 1969 | rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); | ||
| 1970 | expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); | ||
| 1971 | }); | ||
| 1972 | |||
| 1973 | it("does not answer for a checkout it has not seen", () => { | ||
| 1974 | expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); | ||
| 1975 | expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); | ||
| 1976 | }); | ||
| 1977 | |||
| 1978 | it("keeps environments apart", () => { | ||
| 1979 | rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); | ||
| 1980 | expect( | ||
| 1981 | recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), | ||
| 1982 | ).toBeUndefined(); | ||
| 1983 | }); | ||
| 1984 | |||
| 1985 | it("does not confuse an environment id containing the separator with a path", () => { | ||
| 1986 | rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); | ||
| 1987 | expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); | ||
| 1988 | }); | ||
| 1989 | }); | ||
| 1990 | |||
| 1991 | describe("threadShellHasStarted", () => { | ||
| 1992 | it("counts a thread that has a user message but no latest turn", () => { | ||
| 1993 | expect( | ||
| 1994 | threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), | ||
| 1995 | ).toBe(true); | ||
| 1996 | }); | ||
| 1997 | |||
| 1998 | it("counts a thread with a live session and nothing else", () => { | ||
| 1999 | expect( | ||
| 2000 | threadShellHasStarted({ | ||
| 2001 | latestTurn: null, | ||
| 2002 | latestUserMessageAt: null, | ||
| 2003 | session: { | ||
| 2004 | threadId, | ||
| 2005 | status: "starting", | ||
| 2006 | providerName: "codex", | ||
| 2007 | runtimeMode: "full-access", | ||
| 2008 | activeTurnId: null, | ||
| 2009 | lastError: null, | ||
| 2010 | updatedAt: now, | ||
| 2011 | }, | ||
| 2012 | }), | ||
| 2013 | ).toBe(true); | ||
| 2014 | }); | ||
| 2015 | |||
| 2016 | it("does not count a thread that never sent anything", () => { | ||
| 2017 | expect( | ||
| 2018 | threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), | ||
| 2019 | ).toBe(false); | ||
| 2020 | expect(threadShellHasStarted(null)).toBe(false); | ||
| 2021 | }); | ||
| 2022 | }); |
apps/web/src/components/ChatView.logic.ts+43| ... | @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean | ... | @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean |
| 794 | return key !== null && sessionDismissedBranchMismatchKeys.has(key); | 794 | return key !== null && sessionDismissedBranchMismatchKeys.has(key); |
| 795 | } | 795 | } |
| 796 | 796 | ||
| 797 | // Git status for a checkout arrives after the composer paints, and the branch | ||
| 798 | // strip mounts on the assumption that a project is a Git repo. Without a | ||
| 799 | // memory, a non-Git project would mount the strip and drop it on every visit. | ||
| 800 | // Keyed by environment and checkout for the session; never persisted. | ||
| 801 | const sessionCheckoutIsRepo = new Map<string, boolean>(); | ||
| 802 | |||
| 803 | function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { | ||
| 804 | return JSON.stringify([environmentId, cwd]); | ||
| 805 | } | ||
| 806 | |||
| 807 | export function rememberCheckoutIsRepo( | ||
| 808 | environmentId: EnvironmentId, | ||
| 809 | cwd: string, | ||
| 810 | isRepo: boolean, | ||
| 811 | ): void { | ||
| 812 | sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); | ||
| 813 | } | ||
| 814 | |||
| 815 | export function recallCheckoutIsRepo( | ||
| 816 | environmentId: EnvironmentId, | ||
| 817 | cwd: string | null, | ||
| 818 | ): boolean | undefined { | ||
| 819 | return cwd === null | ||
| 820 | ? undefined | ||
| 821 | : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); | ||
| 822 | } | ||
| 823 | |||
| 797 | export function threadHasStarted(thread: Thread | null | undefined): boolean { | 824 | export function threadHasStarted(thread: Thread | null | undefined): boolean { |
| 798 | return Boolean( | 825 | return Boolean( |
| 799 | thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), | 826 | thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), |
| 800 | ); | 827 | ); |
| 801 | } | 828 | } |
| 802 | 829 | ||
| 830 | /** | ||
| 831 | * Whether a thread ran at least one turn, judged from its shell alone. | ||
| 832 | * | ||
| 833 | * `threadHasStarted` needs the detail: a thread whose latest turn was cleared | ||
| 834 | * still has messages, and the loading shell carries none. The shell records | ||
| 835 | * when the last user message landed, which every started thread has. | ||
| 836 | */ | ||
| 837 | export function threadShellHasStarted( | ||
| 838 | shell: Pick<ThreadShell, "latestTurn" | "latestUserMessageAt" | "session"> | null | undefined, | ||
| 839 | ): boolean { | ||
| 840 | return Boolean( | ||
| 841 | shell && | ||
| 842 | (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), | ||
| 843 | ); | ||
| 844 | } | ||
| 845 | |||
| 803 | // Imported history has no session until its first prompt. Resolve its instance | 846 | // Imported history has no session until its first prompt. Resolve its instance |
| 804 | // through the environment's provider catalog before locking to a driver. | 847 | // through the environment's provider catalog before locking to a driver. |
| 805 | export function deriveLockedProvider(input: { | 848 | export function deriveLockedProvider(input: { |
apps/web/src/components/ChatView.tsx+15-2| ... | @@ -403,6 +403,8 @@ import { | ... | @@ -403,6 +403,8 @@ import { |
| 403 | readFileAsDataUrl, | 403 | readFileAsDataUrl, |
| 404 | resolveFileAttachmentUrl, | 404 | resolveFileAttachmentUrl, |
| 405 | reconcileMountedTerminalThreadIds, | 405 | reconcileMountedTerminalThreadIds, |
| 406 | recallCheckoutIsRepo, | ||
| 407 | rememberCheckoutIsRepo, | ||
| 406 | resolveBackgroundDraftWorkspaceOptions, | 408 | resolveBackgroundDraftWorkspaceOptions, |
| 407 | resolveComposerInteractionMode, | 409 | resolveComposerInteractionMode, |
| 408 | resolveComposerProviderSelection, | 410 | resolveComposerProviderSelection, |
| ... | @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { |
| 3327 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; | 3329 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| 3328 | const activeTerminalLaunchContext = | 3330 | const activeTerminalLaunchContext = |
| 3329 | terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; | 3331 | terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; |
| 3330 | // Default true while loading to avoid toolbar flicker. | 3332 | // Git status arrives after the composer paints. A checkout seen earlier in |
| 3331 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 3333 | // this session answers from memory, so a non-Git project does not mount the |
| 3334 | // branch strip and then drop it. A never-seen checkout assumes Git, which | ||
| 3335 | // is what nearly every project is. | ||
| 3336 | const liveIsGitRepo = gitStatusQuery.data?.isRepo; | ||
| 3337 | useEffect(() => { | ||
| 3338 | if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { | ||
| 3339 | rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); | ||
| 3340 | } | ||
| 3341 | }, [environmentId, gitStatusCwd, liveIsGitRepo]); | ||
| 3342 | const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; | ||
| 3332 | // Keep a hidden, off-flow strip mounted for existing threads so the composer | 3343 | // Keep a hidden, off-flow strip mounted for existing threads so the composer |
| 3333 | // can measure whether its relocated controls fit. The visible chrome remains | 3344 | // can measure whether its relocated controls fit. The visible chrome remains |
| 3334 | // content-driven: Git/environment context or controls that actually fit. | 3345 | // content-driven: Git/environment context or controls that actually fit. |
| ... | @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8341 | activeThreadId={activeThreadId} | 8352 | activeThreadId={activeThreadId} |
| 8342 | activeThreadEnvironmentId={activeThread?.environmentId} | 8353 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 8343 | activeThread={activeThread} | 8354 | activeThread={activeThread} |
| 8355 | activeThreadShell={routeServerThreadShell} | ||
| 8344 | promptHistoryMessages={timelineMessages} | 8356 | promptHistoryMessages={timelineMessages} |
| 8345 | isServerThread={isServerThread} | 8357 | isServerThread={isServerThread} |
| 8346 | isLocalDraftThread={isLocalDraftThread} | 8358 | isLocalDraftThread={isLocalDraftThread} |
| ... | @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8386 | interactionMode={interactionMode} | 8398 | interactionMode={interactionMode} |
| 8387 | lockedProvider={lockedProvider} | 8399 | lockedProvider={lockedProvider} |
| 8388 | providerStatuses={providerStatuses as ServerProvider[]} | 8400 | providerStatuses={providerStatuses as ServerProvider[]} |
| 8401 | providerCatalogKnown={serverConfig !== null} | ||
| 8389 | activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} | 8402 | activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} |
| 8390 | activeThreadModelSelection={activeThread?.modelSelection} | 8403 | activeThreadModelSelection={activeThread?.modelSelection} |
| 8391 | activeContextWindow={activeContextWindow} | 8404 | activeContextWindow={activeContextWindow} |
apps/web/src/components/chat/ChatComposer.tsx+52-9| ... | @@ -63,6 +63,7 @@ import { | ... | @@ -63,6 +63,7 @@ import { |
| 63 | readFileAsDataUrl, | 63 | readFileAsDataUrl, |
| 64 | resolveComposerInteractionMode, | 64 | resolveComposerInteractionMode, |
| 65 | resolveComposerProviderSelection, | 65 | resolveComposerProviderSelection, |
| 66 | threadShellHasStarted, | ||
| 66 | } from "../ChatView.logic"; | 67 | } from "../ChatView.logic"; |
| 67 | import { | 68 | import { |
| 68 | dataTransferHasComposerMention, | 69 | dataTransferHasComposerMention, |
| ... | @@ -198,10 +199,11 @@ import { | ... | @@ -198,10 +199,11 @@ import { |
| 198 | renderProviderTraitsMenuContent, | 199 | renderProviderTraitsMenuContent, |
| 199 | renderProviderTraitsPicker, | 200 | renderProviderTraitsPicker, |
| 200 | } from "./composerProviderState"; | 201 | } from "./composerProviderState"; |
| 201 | import { ContextWindowMeter } from "./ContextWindowMeter"; | 202 | import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; |
| 202 | import { | 203 | import { |
| 203 | providerSupportsManualCompaction, | 204 | providerSupportsManualCompaction, |
| 204 | resolveContextWindowModelDisplayName, | 205 | resolveContextWindowModelDisplayName, |
| 206 | shouldReserveContextWindowMeter, | ||
| 205 | } from "./ContextWindowMeter.logic"; | 207 | } from "./ContextWindowMeter.logic"; |
| 206 | import { | 208 | import { |
| 207 | attachVideoThumbnail, | 209 | attachVideoThumbnail, |
| ... | @@ -866,7 +868,13 @@ import { | ... | @@ -866,7 +868,13 @@ import { |
| 866 | } from "../../providerInstances"; | 868 | } from "../../providerInstances"; |
| 867 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; | 869 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; |
| 868 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | 870 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 869 | import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; | 871 | import { |
| 872 | type ChatMessage, | ||
| 873 | type SessionPhase, | ||
| 874 | type Thread, | ||
| 875 | type ThreadShell, | ||
| 876 | videoMimeType, | ||
| 877 | } from "../../types"; | ||
| 870 | import { | 878 | import { |
| 871 | buildComposerPromptHistoryEntries, | 879 | buildComposerPromptHistoryEntries, |
| 872 | stepComposerPromptHistory, | 880 | stepComposerPromptHistory, |
| ... | @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1107 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { | 1115 | const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { |
| 1108 | compact: boolean; | 1116 | compact: boolean; |
| 1109 | activeContextWindow: ContextWindowSnapshot | null; | 1117 | activeContextWindow: ContextWindowSnapshot | null; |
| 1118 | reserveContextWindowMeter: boolean; | ||
| 1110 | activeThreadModelDisplayName: string | null; | 1119 | activeThreadModelDisplayName: string | null; |
| 1111 | isPreparingWorktree: boolean; | 1120 | isPreparingWorktree: boolean; |
| 1112 | pendingAction: { | 1121 | pendingAction: { |
| ... | @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 1143 | compactDisabled={props.compactDisabled} | 1152 | compactDisabled={props.compactDisabled} |
| 1144 | compactDisabledReason={props.compactDisabledReason} | 1153 | compactDisabledReason={props.compactDisabledReason} |
| 1145 | /> | 1154 | /> |
| 1155 | ) : props.reserveContextWindowMeter ? ( | ||
| 1156 | <ContextWindowMeterPlaceholder /> | ||
| 1146 | ) : null} | 1157 | ) : null} |
| 1147 | <ComposerPrimaryActions | 1158 | <ComposerPrimaryActions |
| 1148 | compact={props.compact} | 1159 | compact={props.compact} |
| ... | @@ -1242,6 +1253,8 @@ export interface ChatComposerProps { | ... | @@ -1242,6 +1253,8 @@ export interface ChatComposerProps { |
| 1242 | activeThreadId: ThreadId | null; | 1253 | activeThreadId: ThreadId | null; |
| 1243 | activeThreadEnvironmentId: EnvironmentId | undefined; | 1254 | activeThreadEnvironmentId: EnvironmentId | undefined; |
| 1244 | activeThread: Thread | undefined; | 1255 | activeThread: Thread | undefined; |
| 1256 | /** The routed server thread's shell, present before its detail loads. */ | ||
| 1257 | activeThreadShell: ThreadShell | null; | ||
| 1245 | /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ | 1258 | /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ |
| 1246 | promptHistoryMessages: ReadonlyArray<ChatMessage>; | 1259 | promptHistoryMessages: ReadonlyArray<ChatMessage>; |
| 1247 | isServerThread: boolean; | 1260 | isServerThread: boolean; |
| ... | @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { | ... | @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { |
| 1298 | // Provider / model | 1311 | // Provider / model |
| 1299 | lockedProvider: ProviderDriverKind | null; | 1312 | lockedProvider: ProviderDriverKind | null; |
| 1300 | providerStatuses: ServerProvider[]; | 1313 | providerStatuses: ServerProvider[]; |
| 1314 | /** False until the environment's server config has arrived at least once. */ | ||
| 1315 | providerCatalogKnown: boolean; | ||
| 1301 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; | 1316 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; |
| 1302 | activeThreadModelSelection: ModelSelection | null | undefined; | 1317 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 1303 | 1318 | ||
| ... | @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1416 | interactionMode: requestedInteractionMode, | 1431 | interactionMode: requestedInteractionMode, |
| 1417 | lockedProvider, | 1432 | lockedProvider, |
| 1418 | providerStatuses, | 1433 | providerStatuses, |
| 1434 | providerCatalogKnown, | ||
| 1419 | activeProjectDefaultModelSelection, | 1435 | activeProjectDefaultModelSelection, |
| 1420 | activeThreadModelSelection, | 1436 | activeThreadModelSelection, |
| 1421 | activeContextWindow, | 1437 | activeContextWindow, |
| ... | @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1712 | const selectedInstanceId = | 1728 | const selectedInstanceId = |
| 1713 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; | 1729 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; |
| 1714 | const noProviderAvailable = selectedProviderEntry === undefined; | 1730 | const noProviderAvailable = selectedProviderEntry === undefined; |
| 1731 | // Before the catalog arrives, every thread resolves to "no provider". Send | ||
| 1732 | // stays blocked either way; only the chrome waits, keeping the picker with | ||
| 1733 | // the thread's own selection instead of swapping in the setup button and | ||
| 1734 | // back once the catalog lands. | ||
| 1735 | const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; | ||
| 1736 | const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; | ||
| 1715 | const providerSetupInstanceId = noProviderAvailable | 1737 | const providerSetupInstanceId = noProviderAvailable |
| 1716 | ? (unavailableProviderInstanceId ?? | 1738 | ? (unavailableProviderInstanceId ?? |
| 1717 | (lockedProvider === null | 1739 | (lockedProvider === null |
| ... | @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1887 | () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), | 1909 | () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), |
| 1888 | [activeThreadModelSelection, modelOptionsByInstance], | 1910 | [activeThreadModelSelection, modelOptionsByInstance], |
| 1889 | ); | 1911 | ); |
| 1912 | const reserveContextWindowMeter = shouldReserveContextWindowMeter({ | ||
| 1913 | meterEnabled: settings.contextWindowMeterEnabled, | ||
| 1914 | detailLoading: props.threadSyncPhase === "loading", | ||
| 1915 | threadStarted: threadShellHasStarted(props.activeThreadShell), | ||
| 1916 | providerReportsContextWindow: selectedProviderStatus | ||
| 1917 | ? selectedProviderStatus.reportsContextWindow === true | ||
| 1918 | : null, | ||
| 1919 | }); | ||
| 1890 | 1920 | ||
| 1891 | // ------------------------------------------------------------------ | 1921 | // ------------------------------------------------------------------ |
| 1892 | // Composer-local state | 1922 | // Composer-local state |
| ... | @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3843 | isStashMenuOpen || | 3873 | isStashMenuOpen || |
| 3844 | isDragOverComposer || | 3874 | isDragOverComposer || |
| 3845 | isPreparingWorktree || | 3875 | isPreparingWorktree || |
| 3846 | noProviderAvailable || | 3876 | showProviderUnavailable || |
| 3847 | projectSelectionRequired || | 3877 | projectSelectionRequired || |
| 3848 | environmentUnavailable !== null || | 3878 | environmentUnavailable !== null || |
| 3849 | composerSubmissionError !== null || | 3879 | composerSubmissionError !== null || |
| ... | @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4088 | const hiddenRestingBlockIds = restingBlockDefs | 4118 | const hiddenRestingBlockIds = restingBlockDefs |
| 4089 | .slice(restingBlockDefs.length - restingHiddenBlockCount) | 4119 | .slice(restingBlockDefs.length - restingHiddenBlockCount) |
| 4090 | .map((def) => def.id); | 4120 | .map((def) => def.id); |
| 4091 | const composerControls = noProviderAvailable ? ( | 4121 | const composerControls = showProviderUnavailable ? ( |
| 4092 | <Button | 4122 | <Button |
| 4093 | type="button" | 4123 | type="button" |
| 4094 | size="sm" | 4124 | size="sm" |
| ... | @@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4117 | <ProviderModelPicker | 4147 | <ProviderModelPicker |
| 4118 | isComposerOwned | 4148 | isComposerOwned |
| 4119 | compact={composerControlsCompact} | 4149 | compact={composerControlsCompact} |
| 4120 | activeInstanceId={selectedInstanceId} | 4150 | disabled={providerCatalogPending} |
| 4121 | model={selectedModelForPickerWithCustomFallback} | 4151 | activeInstanceId={ |
| 4152 | providerCatalogPending | ||
| 4153 | ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) | ||
| 4154 | : selectedInstanceId | ||
| 4155 | } | ||
| 4156 | model={ | ||
| 4157 | providerCatalogPending | ||
| 4158 | ? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback) | ||
| 4159 | : selectedModelForPickerWithCustomFallback | ||
| 4160 | } | ||
| 4122 | lockedProvider={lockedProvider} | 4161 | lockedProvider={lockedProvider} |
| 4123 | lockedContinuationGroupKey={lockedContinuationGroupKey} | 4162 | lockedContinuationGroupKey={lockedContinuationGroupKey} |
| 4124 | instanceEntries={providerInstanceEntries} | 4163 | instanceEntries={providerInstanceEntries} |
| ... | @@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5172 | : activePendingProgress.customAnswer || | 5211 | : activePendingProgress.customAnswer || |
| 5173 | "Type your own answer, or leave this blank to use the selected option" | 5212 | "Type your own answer, or leave this blank to use the selected option" |
| 5174 | : prompt.trim() || | 5213 | : prompt.trim() || |
| 5175 | (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} | 5214 | (showProviderUnavailable |
| 5215 | ? "Enable a provider in Settings" | ||
| 5216 | : "Ask anything...")} | ||
| 5176 | </button> | 5217 | </button> |
| 5177 | {collapsedComposerImagePreviews} | 5218 | {collapsedComposerImagePreviews} |
| 5178 | <button | 5219 | <button |
| ... | @@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5631 | "relative", | 5672 | "relative", |
| 5632 | isComposerResting && "flex min-w-0 items-center gap-1", | 5673 | isComposerResting && "flex min-w-0 items-center gap-1", |
| 5633 | isComposerResting && | 5674 | isComposerResting && |
| 5634 | (settings.contextWindowMeterEnabled && activeContextWindow | 5675 | ((settings.contextWindowMeterEnabled && activeContextWindow) || |
| 5676 | reserveContextWindowMeter | ||
| 5635 | ? "pr-28" | 5677 | ? "pr-28" |
| 5636 | : showComposerAttachAction | 5678 | : showComposerAttachAction |
| 5637 | ? "pr-20" | 5679 | ? "pr-20" |
| ... | @@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5685 | ? "Add feedback to refine the plan, or leave this blank to implement it" | 5727 | ? "Add feedback to refine the plan, or leave this blank to implement it" |
| 5686 | : projectSelectionRequired | 5728 | : projectSelectionRequired |
| 5687 | ? "Choose a project above to start a thread" | 5729 | ? "Choose a project above to start a thread" |
| 5688 | : noProviderAvailable | 5730 | : showProviderUnavailable |
| 5689 | ? "Enable a provider in Settings to send a message" | 5731 | ? "Enable a provider in Settings to send a message" |
| 5690 | : phase === "disconnected" | 5732 | : phase === "disconnected" |
| 5691 | ? DISCONNECTED_COMPOSER_PLACEHOLDER | 5733 | ? DISCONNECTED_COMPOSER_PLACEHOLDER |
| ... | @@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5808 | activeContextWindow={ | 5850 | activeContextWindow={ |
| 5809 | settings.contextWindowMeterEnabled ? activeContextWindow : null | 5851 | settings.contextWindowMeterEnabled ? activeContextWindow : null |
| 5810 | } | 5852 | } |
| 5853 | reserveContextWindowMeter={reserveContextWindowMeter} | ||
| 5811 | activeThreadModelDisplayName={activeThreadModelDisplayName} | 5854 | activeThreadModelDisplayName={activeThreadModelDisplayName} |
| 5812 | pendingAction={pendingPrimaryAction} | 5855 | pendingAction={pendingPrimaryAction} |
| 5813 | isRunning={phase === "running"} | 5856 | isRunning={phase === "running"} |
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+50| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | hasDismissedResumeCompaction, | 7 | hasDismissedResumeCompaction, |
| 8 | resolveContextWindowModelDisplayName, | 8 | resolveContextWindowModelDisplayName, |
| 9 | shouldOfferResumeCompaction, | 9 | shouldOfferResumeCompaction, |
| 10 | shouldReserveContextWindowMeter, | ||
| 10 | } from "./ContextWindowMeter.logic"; | 11 | } from "./ContextWindowMeter.logic"; |
| 11 | 12 | ||
| 12 | function claudeProvider(input: { | 13 | function claudeProvider(input: { |
| ... | @@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => { | ... | @@ -236,3 +237,52 @@ describe("hasDismissedResumeCompaction", () => { |
| 236 | ).toBe(false); | 237 | ).toBe(false); |
| 237 | }); | 238 | }); |
| 238 | }); | 239 | }); |
| 240 | |||
| 241 | describe("shouldReserveContextWindowMeter", () => { | ||
| 242 | const loadingStartedThread = { | ||
| 243 | meterEnabled: true, | ||
| 244 | detailLoading: true, | ||
| 245 | threadStarted: true, | ||
| 246 | providerReportsContextWindow: true, | ||
| 247 | }; | ||
| 248 | |||
| 249 | it("holds the meter's slot while a started thread's detail loads", () => { | ||
| 250 | expect(shouldReserveContextWindowMeter(loadingStartedThread)).toBe(true); | ||
| 251 | }); | ||
| 252 | |||
| 253 | it("reserves nothing once the detail is in", () => { | ||
| 254 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, detailLoading: false })).toBe( | ||
| 255 | false, | ||
| 256 | ); | ||
| 257 | }); | ||
| 258 | |||
| 259 | it("reserves nothing for a thread that never ran a turn", () => { | ||
| 260 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, threadStarted: false })).toBe( | ||
| 261 | false, | ||
| 262 | ); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("reserves while the thread's provider is not in the catalog yet", () => { | ||
| 266 | expect( | ||
| 267 | shouldReserveContextWindowMeter({ | ||
| 268 | ...loadingStartedThread, | ||
| 269 | providerReportsContextWindow: null, | ||
| 270 | }), | ||
| 271 | ).toBe(true); | ||
| 272 | }); | ||
| 273 | |||
| 274 | it("reserves nothing for a provider that does not stream usage", () => { | ||
| 275 | expect( | ||
| 276 | shouldReserveContextWindowMeter({ | ||
| 277 | ...loadingStartedThread, | ||
| 278 | providerReportsContextWindow: false, | ||
| 279 | }), | ||
| 280 | ).toBe(false); | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("reserves nothing while the meter is switched off", () => { | ||
| 284 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, meterEnabled: false })).toBe( | ||
| 285 | false, | ||
| 286 | ); | ||
| 287 | }); | ||
| 288 | }); |
apps/web/src/components/chat/ContextWindowMeter.logic.ts+27| ... | @@ -108,3 +108,30 @@ export function formatContextWindowCompactionMessage( | ... | @@ -108,3 +108,30 @@ export function formatContextWindowCompactionMessage( |
| 108 | ? `Context for ${modelDisplayName} compacts automatically when needed.` | 108 | ? `Context for ${modelDisplayName} compacts automatically when needed.` |
| 109 | : "Context compacts automatically when needed."; | 109 | : "Context compacts automatically when needed."; |
| 110 | } | 110 | } |
| 111 | |||
| 112 | /** | ||
| 113 | * Whether the footer should hold the meter's slot before a snapshot exists. | ||
| 114 | * | ||
| 115 | * The snapshot comes from thread activities, which load after the shell. | ||
| 116 | * Reserving the slot while the detail loads, for a started thread, keeps the | ||
| 117 | * attach button still until the meter mounts. Once the detail is in, a | ||
| 118 | * missing snapshot means there is no usage to show and nothing is reserved. | ||
| 119 | * | ||
| 120 | * The meter renders from stored activities whatever the provider's state, so | ||
| 121 | * only a provider known not to stream usage skips the reservation. An unknown | ||
| 122 | * provider (catalog still loading, or the thread's provider disabled) reserves. | ||
| 123 | */ | ||
| 124 | export function shouldReserveContextWindowMeter(input: { | ||
| 125 | readonly meterEnabled: boolean; | ||
| 126 | readonly detailLoading: boolean; | ||
| 127 | readonly threadStarted: boolean; | ||
| 128 | /** `null` while the thread's provider is not in the catalog. */ | ||
| 129 | readonly providerReportsContextWindow: boolean | null; | ||
| 130 | }): boolean { | ||
| 131 | return ( | ||
| 132 | input.meterEnabled && | ||
| 133 | input.detailLoading && | ||
| 134 | input.threadStarted && | ||
| 135 | input.providerReportsContextWindow !== false | ||
| 136 | ); | ||
| 137 | } |
apps/web/src/components/chat/ContextWindowMeter.tsx+5| ... | @@ -161,3 +161,8 @@ export function ContextWindowMeter(props: { | ... | @@ -161,3 +161,8 @@ export function ContextWindowMeter(props: { |
| 161 | </Popover> | 161 | </Popover> |
| 162 | ); | 162 | ); |
| 163 | } | 163 | } |
| 164 | |||
| 165 | /** Holds the meter's footprint while a thread's activities are still loading. */ | ||
| 166 | export function ContextWindowMeterPlaceholder() { | ||
| 167 | return <span aria-hidden="true" className="size-7 shrink-0" />; | ||
| 168 | } |
packages/contracts/src/server.ts+3| ... | @@ -197,6 +197,9 @@ export const ServerProvider = Schema.Struct({ | ... | @@ -197,6 +197,9 @@ export const ServerProvider = Schema.Struct({ |
| 197 | badgeLabel: Schema.optional(TrimmedNonEmptyString), | 197 | badgeLabel: Schema.optional(TrimmedNonEmptyString), |
| 198 | continuation: Schema.optional(ServerProviderContinuation), | 198 | continuation: Schema.optional(ServerProviderContinuation), |
| 199 | showInteractionModeToggle: Schema.optional(Schema.Boolean), | 199 | showInteractionModeToggle: Schema.optional(Schema.Boolean), |
| 200 | // The driver streams context window usage, so a started thread will have a | ||
| 201 | // meter once its activities load. Clients reserve the meter's space on it. | ||
| 202 | reportsContextWindow: Schema.optional(Schema.Boolean), | ||
| 200 | requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), | 203 | requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), |
| 201 | supportsConversationRollback: Schema.optional(Schema.Boolean), | 204 | supportsConversationRollback: Schema.optional(Schema.Boolean), |
| 202 | supportsTextGeneration: Schema.optional(Schema.Boolean), | 205 | supportsTextGeneration: Schema.optional(Schema.Boolean), |