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
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili
const CLAUDE_PRESENTATION = {
displayName: "Claude",
showInteractionModeToggle: true,
reportsContextWindow: true,
} as const;
function toTitleCaseWords(value: string): string {
const parts: Array<string> = [];
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
const CODEX_PRESENTATION = {
displayName: "Codex",
showInteractionModeToggle: true,
reportsContextWindow: true,
} as const;

export interface CodexAppServerProviderSnapshot {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/providerSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface ServerProviderPresentation {
readonly displayName: string;
readonly badgeLabel?: string;
readonly showInteractionModeToggle?: boolean;
readonly reportsContextWindow?: boolean;
readonly requiresNewThreadForModelChange?: boolean;
}

Expand Down Expand Up @@ -212,6 +213,9 @@ export function buildServerProvider(input: {
...(typeof input.presentation.showInteractionModeToggle === "boolean"
? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
: {}),
...(typeof input.presentation.reportsContextWindow === "boolean"
? { reportsContextWindow: input.presentation.reportsContextWindow }
: {}),
...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
: {}),
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
recallCheckoutIsRepo,
rememberCheckoutIsRepo,
resolveBackgroundDraftWorkspaceOptions,
resolveComposerInteractionMode,
resolveComposerProviderSelection,
Expand All @@ -55,6 +57,7 @@ import {
resolveProactiveTurnDiffAction,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
threadShellHasStarted,
resolveDraftHeroState,
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
Expand Down Expand Up @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => {
expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
});
});

describe("checkout Git memory", () => {
it("answers from the last status seen for the same checkout", () => {
rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false);
expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false);
rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true);
expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true);
});

it("does not answer for a checkout it has not seen", () => {
expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined();
expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined();
});

it("keeps environments apart", () => {
rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false);
expect(
recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"),
).toBeUndefined();
});

it("does not confuse an environment id containing the separator with a path", () => {
rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false);
expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined();
});
});

describe("threadShellHasStarted", () => {
it("counts a thread that has a user message but no latest turn", () => {
expect(
threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }),
).toBe(true);
});

it("counts a thread with a live session and nothing else", () => {
expect(
threadShellHasStarted({
latestTurn: null,
latestUserMessageAt: null,
session: {
threadId,
status: "starting",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: now,
},
}),
).toBe(true);
});

it("does not count a thread that never sent anything", () => {
expect(
threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }),
).toBe(false);
expect(threadShellHasStarted(null)).toBe(false);
});
});
43 changes: 43 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean
return key !== null && sessionDismissedBranchMismatchKeys.has(key);
}

// Git status for a checkout arrives after the composer paints, and the branch
// strip mounts on the assumption that a project is a Git repo. Without a
// memory, a non-Git project would mount the strip and drop it on every visit.
// Keyed by environment and checkout for the session; never persisted.
const sessionCheckoutIsRepo = new Map<string, boolean>();

function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string {
return JSON.stringify([environmentId, cwd]);
}

export function rememberCheckoutIsRepo(
environmentId: EnvironmentId,
cwd: string,
isRepo: boolean,
): void {
sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo);
}

export function recallCheckoutIsRepo(
environmentId: EnvironmentId,
cwd: string | null,
): boolean | undefined {
return cwd === null
? undefined
: sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd));
}

export function threadHasStarted(thread: Thread | null | undefined): boolean {
return Boolean(
thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null),
);
}

/**
* Whether a thread ran at least one turn, judged from its shell alone.
*
* `threadHasStarted` needs the detail: a thread whose latest turn was cleared
* still has messages, and the loading shell carries none. The shell records
* when the last user message landed, which every started thread has.
*/
export function threadShellHasStarted(
shell: Pick<ThreadShell, "latestTurn" | "latestUserMessageAt" | "session"> | null | undefined,
): boolean {
return Boolean(
shell &&
(shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null),
);
}

// Imported history has no session until its first prompt. Resolve its instance
// through the environment's provider catalog before locking to a driver.
export function deriveLockedProvider(input: {
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ import {
readFileAsDataUrl,
resolveFileAttachmentUrl,
reconcileMountedTerminalThreadIds,
recallCheckoutIsRepo,
rememberCheckoutIsRepo,
resolveBackgroundDraftWorkspaceOptions,
resolveComposerInteractionMode,
resolveComposerProviderSelection,
Expand Down Expand Up @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) {
const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
const activeTerminalLaunchContext =
terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
// Default true while loading to avoid toolbar flicker.
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
// Git status arrives after the composer paints. A checkout seen earlier in
// this session answers from memory, so a non-Git project does not mount the
// branch strip and then drop it. A never-seen checkout assumes Git, which
// is what nearly every project is.
const liveIsGitRepo = gitStatusQuery.data?.isRepo;
useEffect(() => {
if (gitStatusCwd !== null && liveIsGitRepo !== undefined) {
rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo);
}
}, [environmentId, gitStatusCwd, liveIsGitRepo]);
const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;
// Keep a hidden, off-flow strip mounted for existing threads so the composer
// can measure whether its relocated controls fit. The visible chrome remains
// content-driven: Git/environment context or controls that actually fit.
Expand Down Expand Up @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadId={activeThreadId}
activeThreadEnvironmentId={activeThread?.environmentId}
activeThread={activeThread}
activeThreadShell={routeServerThreadShell}
promptHistoryMessages={timelineMessages}
isServerThread={isServerThread}
isLocalDraftThread={isLocalDraftThread}
Expand Down Expand Up @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) {
interactionMode={interactionMode}
lockedProvider={lockedProvider}
providerStatuses={providerStatuses as ServerProvider[]}
providerCatalogKnown={serverConfig !== null}
activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
activeThreadModelSelection={activeThread?.modelSelection}
activeContextWindow={activeContextWindow}
Expand Down
61 changes: 52 additions & 9 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
readFileAsDataUrl,
resolveComposerInteractionMode,
resolveComposerProviderSelection,
threadShellHasStarted,
} from "../ChatView.logic";
import {
dataTransferHasComposerMention,
Expand Down Expand Up @@ -198,10 +199,11 @@ import {
renderProviderTraitsMenuContent,
renderProviderTraitsPicker,
} from "./composerProviderState";
import { ContextWindowMeter } from "./ContextWindowMeter";
import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter";
import {
providerSupportsManualCompaction,
resolveContextWindowModelDisplayName,
shouldReserveContextWindowMeter,
} from "./ContextWindowMeter.logic";
import {
attachVideoThumbnail,
Expand Down Expand Up @@ -866,7 +868,13 @@ import {
} from "../../providerInstances";
import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
import type { UnifiedSettings } from "@t3tools/contracts/settings";
import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types";
import {
type ChatMessage,
type SessionPhase,
type Thread,
type ThreadShell,
videoMimeType,
} from "../../types";
import {
buildComposerPromptHistoryEntries,
stepComposerPromptHistory,
Expand Down Expand Up @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
compact: boolean;
activeContextWindow: ContextWindowSnapshot | null;
reserveContextWindowMeter: boolean;
activeThreadModelDisplayName: string | null;
isPreparingWorktree: boolean;
pendingAction: {
Expand Down Expand Up @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
compactDisabled={props.compactDisabled}
compactDisabledReason={props.compactDisabledReason}
/>
) : props.reserveContextWindowMeter ? (
<ContextWindowMeterPlaceholder />
) : null}
<ComposerPrimaryActions
compact={props.compact}
Expand Down Expand Up @@ -1242,6 +1253,8 @@ export interface ChatComposerProps {
activeThreadId: ThreadId | null;
activeThreadEnvironmentId: EnvironmentId | undefined;
activeThread: Thread | undefined;
/** The routed server thread's shell, present before its detail loads. */
activeThreadShell: ThreadShell | null;
/** Timeline messages including optimistic sends, for ArrowUp prompt recall. */
promptHistoryMessages: ReadonlyArray<ChatMessage>;
isServerThread: boolean;
Expand Down Expand Up @@ -1298,6 +1311,8 @@ export interface ChatComposerProps {
// Provider / model
lockedProvider: ProviderDriverKind | null;
providerStatuses: ServerProvider[];
/** False until the environment's server config has arrived at least once. */
providerCatalogKnown: boolean;
activeProjectDefaultModelSelection: ModelSelection | null | undefined;
activeThreadModelSelection: ModelSelection | null | undefined;

Expand Down Expand Up @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
interactionMode: requestedInteractionMode,
lockedProvider,
providerStatuses,
providerCatalogKnown,
activeProjectDefaultModelSelection,
activeThreadModelSelection,
activeContextWindow,
Expand Down Expand Up @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const selectedInstanceId =
selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
const noProviderAvailable = selectedProviderEntry === undefined;
// Before the catalog arrives, every thread resolves to "no provider". Send
// stays blocked either way; only the chrome waits, keeping the picker with
// the thread's own selection instead of swapping in the setup button and
// back once the catalog lands.
const providerCatalogPending = noProviderAvailable && !providerCatalogKnown;
const showProviderUnavailable = noProviderAvailable && !providerCatalogPending;
const providerSetupInstanceId = noProviderAvailable
? (unavailableProviderInstanceId ??
(lockedProvider === null
Expand Down Expand Up @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
() => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance),
[activeThreadModelSelection, modelOptionsByInstance],
);
const reserveContextWindowMeter = shouldReserveContextWindowMeter({
meterEnabled: settings.contextWindowMeterEnabled,
detailLoading: props.threadSyncPhase === "loading",
threadStarted: threadShellHasStarted(props.activeThreadShell),
providerReportsContextWindow: selectedProviderStatus
? selectedProviderStatus.reportsContextWindow === true
: null,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ------------------------------------------------------------------
// Composer-local state
Expand Down Expand Up @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isStashMenuOpen ||
isDragOverComposer ||
isPreparingWorktree ||
noProviderAvailable ||
showProviderUnavailable ||
projectSelectionRequired ||
environmentUnavailable !== null ||
composerSubmissionError !== null ||
Expand Down Expand Up @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const hiddenRestingBlockIds = restingBlockDefs
.slice(restingBlockDefs.length - restingHiddenBlockCount)
.map((def) => def.id);
const composerControls = noProviderAvailable ? (
const composerControls = showProviderUnavailable ? (
<Button
type="button"
size="sm"
Expand Down Expand Up @@ -4117,8 +4147,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<ProviderModelPicker
isComposerOwned
compact={composerControlsCompact}
activeInstanceId={selectedInstanceId}
model={selectedModelForPickerWithCustomFallback}
disabled={providerCatalogPending}
activeInstanceId={
providerCatalogPending
? (activeThreadModelSelection?.instanceId ?? selectedInstanceId)
: selectedInstanceId
}
model={
providerCatalogPending
? (activeThreadModelSelection?.model ?? selectedModelForPickerWithCustomFallback)
: selectedModelForPickerWithCustomFallback
}
lockedProvider={lockedProvider}
lockedContinuationGroupKey={lockedContinuationGroupKey}
instanceEntries={providerInstanceEntries}
Expand Down Expand Up @@ -5172,7 +5211,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: activePendingProgress.customAnswer ||
"Type your own answer, or leave this blank to use the selected option"
: prompt.trim() ||
(noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
(showProviderUnavailable
? "Enable a provider in Settings"
: "Ask anything...")}
</button>
{collapsedComposerImagePreviews}
<button
Expand Down Expand Up @@ -5631,7 +5672,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
"relative",
isComposerResting && "flex min-w-0 items-center gap-1",
isComposerResting &&
(settings.contextWindowMeterEnabled && activeContextWindow
((settings.contextWindowMeterEnabled && activeContextWindow) ||
reserveContextWindowMeter
? "pr-28"
: showComposerAttachAction
? "pr-20"
Expand Down Expand Up @@ -5685,7 +5727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? "Add feedback to refine the plan, or leave this blank to implement it"
: projectSelectionRequired
? "Choose a project above to start a thread"
: noProviderAvailable
: showProviderUnavailable
? "Enable a provider in Settings to send a message"
: phase === "disconnected"
? DISCONNECTED_COMPOSER_PLACEHOLDER
Expand Down Expand Up @@ -5808,6 +5850,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeContextWindow={
settings.contextWindowMeterEnabled ? activeContextWindow : null
}
reserveContextWindowMeter={reserveContextWindowMeter}
activeThreadModelDisplayName={activeThreadModelDisplayName}
pendingAction={pendingPrimaryAction}
isRunning={phase === "running"}
Expand Down
Loading
Loading