diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 8be0f076..0467a165 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2193,6 +2193,23 @@ const dispatchDeepLink = (link) => { log.warn('[electron] invalid connect deep-link payload'); return; } + // Sent by the MCP OAuth callback page after it completes authorization in + // the system browser. The work is already done server-side; all this has to + // do is bring the app back to the front, since the user's attention is in a + // browser tab at that moment. + if (link.type === 'focus') { + const target = state.mainWindow && !state.mainWindow.isDestroyed() + ? state.mainWindow + : BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()); + if (target) { + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + } + emitToAllWindows('openchamber:deep-link-focus', { reason: link.value || null }); + return; + } + if (link.type === 'session' && link.value) { emitToAllWindows('openchamber:open-session', { sessionId: link.value }); return; @@ -3689,6 +3706,22 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_start_window_drag': return null; + // Used after an MCP authorization finishes in the system browser: the app + // raises itself rather than relying on the browser to hand control back. + // A browser will not follow a custom-protocol link without a user gesture, + // and the completion page has none. + case 'desktop_focus_window': { + const target = browserWindow && !browserWindow.isDestroyed() + ? browserWindow + : (state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null); + if (!target) return false; + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + app.focus?.({ steal: true }); + return true; + } + case 'desktop_is_window_fullscreen': return Boolean(browserWindow?.isFullScreen()); diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 85a93ff5..9ffe0d66 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -2,16 +2,15 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; -import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; -import { getDisplayModelName } from '@/lib/quota/model-families'; +import { clampPercent, resolveUsageTone } from '@/lib/quota'; +import { UsageProviderCards } from '@/components/usage/UsageProviderCards'; +import { useUsageProviderGroups, type UsageProviderGroup } from '@/components/usage/usageGroups'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; -import type { QuotaProviderId, UsageWindow } from '@/types'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages } from '@/sync/sync-context'; @@ -34,34 +33,12 @@ const formatTokens = (value: number): string => { return String(value); }; -type MobileUsageLimitRow = { - key: string; - label: string; - subtitle?: string; - window: UsageWindow; -}; - -type MobileUsageProviderGroup = { - providerId: QuotaProviderId; - providerName: string; - rows: MobileUsageLimitRow[]; - status: string | null; -}; - type ContextDisplay = { percentage: number; tokens: string; colorClass: string; } | null; -const getWindowValueClass = (window: UsageWindow): string => { - const usedPercent = window.usedPercent; - if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground'; - if (usedPercent >= 80) return 'text-[var(--status-error)]'; - if (usedPercent >= 50) return 'text-[var(--status-warning)]'; - return 'text-foreground'; -}; - const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => { const progressPct = clampPercent(percentage) ?? 0; const tone = resolveUsageTone(percentage); @@ -130,7 +107,7 @@ const SessionMetadataOverlay: React.FC<{ onClose: () => void; anchorRef: React.RefObject; contextDisplay: ContextDisplay; - usageGroups: MobileUsageProviderGroup[]; + usageGroups: UsageProviderGroup[]; usageDisplayMode: 'usage' | 'remaining'; isUsageLoading: boolean; timeFormatPreference: TimeFormatPreference; @@ -283,7 +260,7 @@ const SessionMetadataOverlay: React.FC<{ }; const MobileUsageLimits: React.FC<{ - groups: MobileUsageProviderGroup[]; + groups: UsageProviderGroup[]; displayMode: 'usage' | 'remaining'; isLoading: boolean; timeFormatPreference: TimeFormatPreference; @@ -318,54 +295,11 @@ const MobileUsageLimits: React.FC<{ -
- {groups.map((group) => ( -
-
- - - {group.providerName} - - {group.status && group.rows.length === 0 ? ( - - {group.status} - - ) : null} -
- {group.rows.length > 0 ? ( -
- {group.rows.map((row) => { - const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent; - const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel( - row.window.resetAt, - row.window.resetAfterFormatted ?? row.window.resetAtFormatted, - timeFormatPreference, - ); - return ( -
- - - {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} - - {resetLabel ? ( - {resetLabel} - ) : null} - - - {metricLabel === '-' ? '' : metricLabel} - -
- ); - })} -
- ) : null} - {group.status && group.rows.length > 0 ? ( -
{group.status}
- ) : null} -
- ))} -
+ ); }; @@ -403,7 +337,6 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta const isQuotaLoading = useQuotaStore((state) => state.isLoading); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); - const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); useQuotaAutoRefresh(); @@ -491,54 +424,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } : null; - const usageGroups = React.useMemo(() => { - const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); - return QUOTA_PROVIDERS - .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) - .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) - .map((providerMeta) => { - const result = resultsByProvider.get(providerMeta.id)!; - const rows: MobileUsageLimitRow[] = []; - - for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { - rows.push({ - key: `window-${label}`, - label: formatWindowLabel(label), - window, - }); - } - - const modelEntries = Object.entries(result?.usage?.models ?? {}); - const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? []; - const visibleModelEntries = providerSelectedModels.length > 0 - ? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName)) - : modelEntries; - for (const [modelName, modelUsage] of visibleModelEntries) { - const entries = Object.entries(modelUsage.windows ?? {}); - if (entries.length === 0) continue; - const [label, window] = entries[0]; - rows.push({ - key: `model-${modelName}-${label}`, - label: formatWindowLabel(label), - subtitle: getDisplayModelName(modelName), - window, - }); - } - - const status = !result.ok && result.error - ? result.error - : rows.length === 0 - ? t('header.services.noRateLimitsReported') - : null; - - return { - providerId: providerMeta.id, - providerName: providerMeta.name, - rows, - status, - }; - }); - }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); + const usageGroups = useUsageProviderGroups(); React.useEffect(() => { if (!open || usageGroups.length === 0) return; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 17aee5c4..6799093a 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -50,6 +50,8 @@ import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { WorkStatusPanel } from './work-status/WorkStatusPanel'; +import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility'; import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; @@ -694,6 +696,49 @@ export const ChatContainer: React.FC = ({ active = true, aut // composer enters the same fullscreen-input mode via its drag handle. const isDesktopExpandedInput = isExpandedInput; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; + // Work-status panel: a borderless column to the right of the transcript. + // It yields to the context panel and to a narrow chat; `rowRef` goes on the + // row that holds both columns, so its width never depends on the panel's + // own visibility. + const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ + directory: effectiveSessionDirectory, + isMobile, + isVSCode, + }); + // Session view only. The draft branch returns its own layout before this + // one, so the panel has no place there yet. + // Surfaces that never host the panel skip it entirely; the rest keep it + // mounted so its visibility can animate rather than snap. + const workStatusPanelMountable = !isMobile + && !isVSCode + && chatSurfaceMode !== 'mini-chat' + && !isDesktopExpandedInput; + const showWorkStatusPanel = workStatusPanelMountable && workStatusVisible; + + // Offered over the chat when there is no room beside it. The panel is still + // switched on; only the layout refuses it. + const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen); + const setWorkStatusPanelFits = useUIStore((state) => state.setWorkStatusPanelFits); + // Mounted whenever it could be shown, not only while it is: an element + // that appears and disappears with the condition has nothing to animate. + const workStatusOverlayMountable = workStatusPanelMountable + && workStatusPanelEnabled + && !workStatusFits; + const showWorkStatusOverlay = workStatusOverlayMountable && workStatusOverlayOpen; + + React.useEffect(() => { + setWorkStatusPanelFits(workStatusPanelMountable && workStatusFits); + return () => setWorkStatusPanelFits(false); + }, [setWorkStatusPanelFits, workStatusFits, workStatusPanelMountable]); + + // Published so the header can drop the readouts the panel already carries. + // Cleared on unmount: a chat that goes away is not showing anything. + const setWorkStatusPanelVisible = useUIStore((state) => state.setWorkStatusPanelVisible); + React.useEffect(() => { + setWorkStatusPanelVisible(showWorkStatusPanel); + return () => setWorkStatusPanelVisible(false); + }, [setWorkStatusPanelVisible, showWorkStatusPanel]); const messageListRef = React.useRef(null); const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); @@ -1152,7 +1197,8 @@ export const ChatContainer: React.FC = ({ active = true, aut } return ( -
+
+
{returnToParentButton} = ({ active = true, aut {promptReadOnly ? : }
+ {/* Inside the chat column, not beside it: as a row sibling it took + part in the flex layout and pushed the transcript, which is the + one thing an overlay must not do. */} + {workStatusOverlayMountable ? ( + + ) : null} + = ({ active = true, aut onLoadEarlier={handleLoadOlderClick} />
+ {/* Kept mounted while it could ever show, so it can animate its own + collapse; `visible` drives that. Unmounting on the spot is what made + the chat jump wide before easing narrow again. */} + {workStatusPanelMountable ? ( + + ) : null} +
); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 9e055d41..89c7fcf6 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -16,6 +16,7 @@ import { } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; +import { buildLinkedIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; @@ -1227,6 +1228,45 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } void sendPromise.then(() => { + // Record what this session was pointed at, so the work-status panel + // can show it as a context source long after the message scrolled + // away. A snapshot only — never re-fetched, never authoritative. + // Failures are swallowed: the message went out, and a missing + // bookkeeping entry must not surface as a send error. + const attachedThread = linkedIssue + ? { attachment: linkedIssue, kind: 'issue' as const } + : linkedPr + ? { attachment: linkedPr, kind: 'pull' as const } + : null; + // On a draft there is no session yet in this closure: the send path + // creates one and makes it current before resolving, so the id is + // read from the store. The fallback is used only when the closure + // had no session at all, so a mid-send session switch cannot + // redirect the write to an unrelated session. + const sessionState = useSessionUIStore.getState(); + const linkTargetSessionId = currentSessionId ?? sessionState.currentSessionId; + const linkTargetDirectory = currentSessionId + ? currentSessionDirectoryForSync ?? currentDirectory + : sessionState.currentSessionDirectory + ?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null) + ?? currentDirectory; + + if (attachedThread && linkTargetSessionId) { + void sessionActions.setLinkedIssue( + linkTargetSessionId, + linkTargetDirectory, + buildLinkedIssue({ + url: attachedThread.attachment.url, + number: attachedThread.attachment.number, + title: attachedThread.attachment.title, + kind: attachedThread.kind, + author: attachedThread.attachment.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + } + // Clear linked issue after successful message send if (linkedIssue) { setLinkedIssue(null); @@ -2221,6 +2261,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); + // The work-status panel carries the agent's todos and the changed-file + // count, but only on the desktop/web layout — VS Code and mobile have no + // panel, so these keep their place above the composer there. + const composerStatusExtrasEnabled = isVSCode || isMobile; const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode; // Which project and directory a new session will target. @@ -2485,8 +2529,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } + showTodos={composerStatusExtrasEnabled} + leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges + ? null + : } /> {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = React.memo(({ const liveGoal = goal && goal.status !== 'complete' ? goal : null; const isEngaged = armed || Boolean(liveGoal); - const colorClass = (() => { - if (goal?.status === 'complete') return 'text-[var(--status-success)]'; - if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]'; - if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]'; - return ''; - })(); + // One mapping for every goal surface. This button used to carry its own, + // which painted `paused` the same info colour as `active` — so a paused goal + // was indistinguishable from a running one — and `blocked` as an error rather + // than a warning. `armed` is not a goal status, so it keeps its own case. + const iconColor = goal + ? sessionGoalStatusColor[goal.status] + : (armed ? 'var(--status-info)' : undefined); const label = goal ? t('chat.goal.button.manageAria') @@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC = React.memo(({ const button = ( + + {contentMounted ? ( + + + } + /> + {sectionVisible('usage') ? : null} + {sectionVisible('subagents') ? : null} + {sectionVisible('tasks') ? : null} + {sectionVisible('mcp') ? : null} + {sectionVisible('pinned') ? : null} + {sectionVisible('contextSources') ? : null} + + + ) : null} + + + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx new file mode 100644 index 00000000..90e9cbaf --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { toast } from 'sonner'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useDirectorySync, useEnsureSessionMessages, useSession } from '@/sync/sync-context'; +import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; +import { setContextObligatoryMessage } from '@/sync/session-actions'; +import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +/** + * Messages pinned into the context. + * + * The row carries two destinations, so the pin is its own button: pressing the + * pin unpins, pressing the text takes you to the message. + */ +export const WorkStatusPinnedSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + const session = useSession(sessionId ?? '', directory ?? undefined); + const parts = useDirectorySync(React.useCallback((state: State) => state.part, [])); + const [busyId, setBusyId] = React.useState(null); + + const pinned = React.useMemo(() => { + const entries = getContextObligatoryMessages(session); + if (entries.length === 0) return []; + return entries.map((entry) => { + const messageParts = parts[entry.id] ?? []; + const text = messageParts.find( + (part): part is Extract => part.type === 'text', + )?.text?.trim(); + return { id: entry.id, text: text || null }; + }); + }, [session, parts]); + + // Pinned messages are most useful on a long session — which is exactly when + // the pinned message has scrolled far enough back not to be loaded, leaving + // the row with a placeholder instead of its text. Materialise the session, + // but only when a pin actually resolves to nothing: having pins is not a + // reason to fetch, and neither is something being unloaded in general. + const hasUnresolvedPin = pinned.length > 0 && pinned.some((entry) => entry.text === null); + useEnsureSessionMessages(sessionId ?? '', directory ?? undefined, hasUnresolvedPin); + + const handleUnpin = React.useCallback(async (messageId: string) => { + if (!sessionId || busyId) return; + setBusyId(messageId); + try { + // Only the id matters when unpinning — `withContextObligatoryMessage` + // filters by it and discards the rest of the payload. + await setContextObligatoryMessage( + sessionId, + directory, + { id: messageId, createdAt: 0, role: 'user' }, + false, + ); + } catch { + toast.error(t('chat.workStatus.pinned.unpinFailed')); + } finally { + setBusyId((current) => (current === messageId ? null : current)); + } + }, [busyId, directory, sessionId, t]); + + // The transcript listens for `#message-` and scrolls there; it is the + // only cross-component jump the chat exposes. An unchanged hash fires no + // event, so clear it first to make a repeat press work. + const handleReveal = React.useCallback((messageId: string) => { + if (typeof window === 'undefined') return; + const target = `#message-${messageId}`; + if (window.location.hash === target) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + window.location.hash = target; + }, []); + + useReportWorkStatusPresence('pinned', pinned.length > 0); + + if (pinned.length === 0) return null; + + return ( + + {pinned.map((entry) => ( + { + event.stopPropagation(); + void handleUnpin(entry.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + muted + label={entry.text ?? t('chat.workStatus.pinned.unavailable')} + onClick={() => handleReveal(entry.id)} + ariaLabel={t('chat.workStatus.pinned.reveal')} + /> + ))} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx new file mode 100644 index 00000000..559ba05a --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -0,0 +1,323 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useGitStore } from '@/stores/useGitStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; +import { useSession, useSessionMessages } from '@/sync/sync-context'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { normalizeProjectPath } from '@/lib/projectResolution'; +import { resolveUsageTone } from '@/lib/quota'; +import { computeContextUsage } from './contextUsage'; +import { + WorkStatusCallout, + WorkStatusMeter, + WorkStatusPill, + WorkStatusRow, + WorkStatusSection, + WorkStatusValue, +} from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; + +type Props = { + sessionId: string | null; + directory: string | null; + /** Rendered first inside the Session section; owns its own dialog. */ + goalRow: React.ReactNode; + showSession: boolean; + showRepository: boolean; +}; + +// Spend is read against a budget, so it keeps its real precision instead of +// collapsing to two decimals. Trailing zeros are dropped so exact values stay +// short. +const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value); +const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`; +// Matches the header readout exactly: one decimal, capped the same way, so the +// two places that report context fill never disagree by a rounding step. +const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`; + +/** + * The persistent readouts — how full the context is, what the working tree and + * the pull request look like. All of it stays true for as long as the session + * is open, so it sits above anything episodic. + */ +export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, goalRow, showSession, showRepository }) => { + const { t } = useI18n(); + const session = useSession(sessionId ?? '', directory ?? undefined); + const { git } = useRuntimeAPIs(); + const ensureStatus = useGitStore((state) => state.ensureStatus); + + const gitStatus = useGitStore( + React.useCallback( + (state) => (directory ? state.directories.get(directory)?.status ?? null : null), + [directory], + ), + ); + + // Warm the shared git cache through the background-network gate so the panel + // never competes with the chat's own bootstrap traffic for sockets. + React.useEffect(() => { + if (!directory || !git) return; + void runBackgroundNetworkTask(() => ensureStatus(directory, git)); + }, [directory, git, ensureStatus]); + + const branch = gitStatus?.current?.trim() || null; + + // The panel's directory can be a worktree, so the project is the registered + // one whose path contains it — longest match wins, since projects can nest. + const projectLabel = useProjectsStore( + React.useCallback((state) => { + const normalizedDirectory = normalizeProjectPath(directory ?? null); + if (!normalizedDirectory) return null; + let best: { path: string; label: string } | null = null; + for (const project of state.projects) { + const projectPath = normalizeProjectPath(project.path); + if (!projectPath) continue; + const contains = normalizedDirectory === projectPath + || normalizedDirectory.startsWith(`${projectPath}/`); + if (!contains) continue; + if (best && best.path.length >= projectPath.length) continue; + const label = project.label?.trim() + || projectPath.split('/').filter(Boolean).pop() + || projectPath; + best = { path: projectPath, label }; + } + return best?.label ?? null; + }, [directory]), + ); + + // Read-only: PR watching is owned by the background tracker. Starting a watch + // here would multiply GitHub requests per open session, which is exactly the + // fan-out the PR-status concurrency gate exists to prevent. + const prKey = React.useMemo( + () => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null), + [directory, branch], + ); + const prSummary = usePrVisualSummary(prKey); + + // `getCurrentModel` is an imperative getter: its reference never changes, so + // calling it in render subscribes to nothing. Subscribe to the selected model + // ids and recompute the limits from those. + const getCurrentModel = useConfigStore((state) => state.getCurrentModel); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const sessionMessages = useSessionMessages(sessionId ?? '', directory ?? undefined); + + const contextLimit = React.useMemo(() => { + const currentModel = getCurrentModel(); + const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null + ? (currentModel.limit as Record) + : null; + return limit && typeof limit.context === 'number' ? limit.context : 0; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getter output tracks the selected model ids + }, [getCurrentModel, currentProviderId, currentModelId]); + + // Computed from this session's own messages rather than through + // `useSessionUIStore.getContextUsage`, which reads the *current* directory's + // store and so loses the readout for any session held elsewhere. See + // `contextUsage.ts`. + const contextUsage = React.useMemo( + () => computeContextUsage(sessionMessages, contextLimit), + [sessionMessages, contextLimit], + ); + + const openContextSurface = useUIStore((state) => state.openContextSurface); + const openContextOverview = useUIStore((state) => state.openContextOverview); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); + const openSurface = React.useCallback( + (mode: 'git' | 'pr') => { if (directory) openContextSurface(directory, mode); }, + [directory, openContextSurface], + ); + // Working-tree diff without a target path: the panel opens on the whole + // change set rather than picking a file on the user's behalf. + // Same destination as the header's context readout. + const openContext = React.useCallback(() => { + if (directory) openContextOverview(directory); + }, [directory, openContextOverview]); + + const openChanges = React.useCallback(() => { + if (directory) openContextPanelTab(directory, { mode: 'diff', diffScope: 'working' }); + }, [directory, openContextPanelTab]); + + // Working-tree changes, from the same git status the Git panel reads. + // + // `Session.summary` looks like the natural source and is not: OpenCode resets + // it to zeros at the start of every turn and only ever fills per-message + // `summary.diffs`, so session-level totals are always 0/0/0. The `session.diff` + // event is reset to an empty array too, and carries real content only on + // revert. Git status is the one authoritative, already-cached answer. + const changed = React.useMemo(() => { + const files = gitStatus?.files ?? []; + if (files.length === 0) return null; + const stats = gitStatus?.diffStats; + let additions = 0; + let deletions = 0; + if (stats) { + for (const entry of Object.values(stats)) { + additions += entry?.insertions ?? 0; + deletions += entry?.deletions ?? 0; + } + } + return { files: files.length, additions, deletions, hasStats: Boolean(stats) }; + }, [gitStatus?.files, gitStatus?.diffStats]); + + const attentionReason = gitStatus?.attentionReason + ?? (gitStatus?.rebaseInProgress ? 'rebase' : null) + ?? (gitStatus?.mergeInProgress ? 'merge' : null); + const attentionLabel = attentionReason === 'merge' ? t('chat.workStatus.attention.merge') + : attentionReason === 'rebase' ? t('chat.workStatus.attention.rebase') + : attentionReason === 'cherry-pick' ? t('chat.workStatus.attention.cherryPick') + : attentionReason === 'revert' ? t('chat.workStatus.attention.revert') + : attentionReason === 'bisect' ? t('chat.workStatus.attention.bisect') + : null; + + const usagePercent = contextUsage?.percent ?? null; + // Colour threshold uses the rounded percentage, matching what the header + // feeds `resolveUsageTone`; the displayed number stays unrounded. + const usageTone = usagePercent === null ? null : resolveUsageTone(Math.round(usagePercent)); + // Same tone ramp as the header's context icon — healthy is success, not + // primary, so a full bar reads as a warning rather than as brand colour. + const meterColor = usageTone === 'critical' ? 'var(--status-error)' + : usageTone === 'warn' ? 'var(--status-warning)' + : 'var(--status-success)'; + + const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null; + const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); + const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel); + + useReportWorkStatusPresence('session-repository', hasSession || hasRepository); + + if (!hasSession && !hasRepository) return null; + + return ( + <> + {hasSession ? ( + + {usagePercent !== null ? ( + <> + + {formatPercent(usagePercent)} + {/* No icon of its own: the sprite has no currency glyph, and + spend belongs with consumption anyway. The `$` labels it. */} + {cost !== null ? {formatCost(cost)} : null} + + )} + /> + + + ) : null} + {/* Below the context readout: the goal is a standing instruction, + while context is the live number the reader came for. */} + {goalRow} + + ) : null} + + {hasRepository ? ( + + {attentionLabel ? {attentionLabel} : null} + + {/* Branch first: the changes below are the changes *on it*, and the + row reads as a caption to the branch rather than a loose number. */} + {branch ? ( + openSurface('git') : undefined} + ariaLabel={t('chat.workStatus.action.openGit')} + label={branch} + value={(gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0 ? ( + <> + {(gitStatus?.ahead ?? 0) > 0 + ? {`↑${gitStatus?.ahead}`} : null} + {(gitStatus?.behind ?? 0) > 0 + ? {`↓${gitStatus?.behind}`} : null} + + ) : undefined} + /> + ) : null} + + {changed ? ( + 0 || changed.deletions > 0) ? ( + <> + {`+${changed.additions}`} + {/* Neutral separator: colouring it would imply it carries a + status of its own. */} + / + {`−${changed.deletions}`} + + ) : undefined} + /> + ) : null} + + {prSummary ? ( + <> + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + iconColor={`var(--pr-${prSummary.visualState})`} + label={prSummary.title ?? t('chat.workStatus.pr.untitled')} + value={( + + {prSummary.draft ? t('chat.workStatus.pr.draft') : `#${prSummary.number}`} + + )} + /> + {prSummary.checks && prSummary.checks.total > 0 ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + label={t('chat.workStatus.pr.checks')} + muted + value={( + <> + {prSummary.checks.failure > 0 ? ( + + {t('chat.workStatus.pr.checksFailed', { count: prSummary.checks.failure })} + + ) : null} + {prSummary.checks.pending > 0 ? ( + + {t('chat.workStatus.pr.checksPending', { count: prSummary.checks.pending })} + + ) : null} + {prSummary.checks.failure === 0 && prSummary.checks.pending === 0 ? ( + + {t('chat.workStatus.pr.checksPassed', { count: prSummary.checks.success })} + + ) : null} + + )} + /> + ) : null} + + ) : null} + + ) : null} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx new file mode 100644 index 00000000..24fe76d7 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx @@ -0,0 +1,266 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { Icon } from '@/components/icon/Icon'; +import { useUIStore } from '@/stores/useUIStore'; +import type { IconName } from '@/components/icon/icons'; + +/** + * Row/section vocabulary for the work-status panel. + * + * Every readout is a labelled row — icon, name, trailing value — so a glance + * answers "what is this number" without hovering. Sections carry a heading and + * are separated by a hairline; the panel itself stays chrome-less, since it is + * an object inside the chat rather than a docked pane. + */ + +/** + * Sections are direct siblings inside the panel (fragments add no DOM nodes), + * so the separator is a first-child CSS rule. Passing "am I first?" down as a + * prop would mean every group tracking what the groups above it decided to + * render. + */ +const SECTION_CLASS = cn( + 'flex flex-col', + '[&:not(:first-child)]:mt-3 [&:not(:first-child)]:border-t', + '[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3', +); + +const HEADING_CLASS = 'text-xs font-normal text-muted-foreground'; + +export const WorkStatusSection: React.FC<{ + title: string; + /** Aggregate for the whole section; belongs on the heading, not on a row. */ + summary?: React.ReactNode; + children: React.ReactNode; +}> = ({ title, summary, children }) => ( +
+
+

{title}

+ {summary !== undefined && summary !== null ? ( + {summary} + ) : null} +
+ {children} +
+); + +/** + * Section whose body folds away. The chevron swaps on expand exactly as the + * transcript's tool blocks do, so the two collapsibles read as the same + * control rather than two conventions in one window. + * + * Expanded state lives in the persisted UI store, not in component state: the + * panel unmounts whenever the context panel opens, and local state would + * silently discard the user's arrangement every time. + */ +export const WorkStatusCollapsibleSection: React.FC<{ + /** Stable key for persisting expanded state. */ + id: string; + title: string; + icon?: IconName; + /** For glyphs that live outside the sprite, such as the MCP mark. */ + iconNode?: React.ReactNode; + iconColor?: string; + /** Shown on the header while collapsed and expanded alike. */ + summary?: React.ReactNode; + defaultExpanded?: boolean; + children: React.ReactNode; +}> = ({ id, title, icon, iconNode, iconColor, summary, defaultExpanded = false, children }) => { + const stored = useUIStore( + React.useCallback((state) => state.workStatusExpandedSections[id], [id]), + ); + const setExpandedInStore = useUIStore((state) => state.setWorkStatusSectionExpanded); + const expanded = stored ?? defaultExpanded; + return ( +
+ + {expanded ? children : null} +
+ ); +}; + +type RowProps = { + icon?: IconName; + iconColor?: string; + leading?: React.ReactNode; + label: React.ReactNode; + value?: React.ReactNode; + muted?: boolean; + /** Turns the row into a button; the caller decides what it opens. */ + onClick?: () => void; + ariaLabel?: string; + className?: string; +}; + +/** + * A single readout. `value` sits hard right; `label` truncates before it, so a + * long branch name never pushes its own ahead/behind counts out of view. + */ +export const WorkStatusRow: React.FC = ({ + icon, + iconColor, + leading, + label, + value, + muted, + onClick, + ariaLabel, + className, +}) => { + const body = ( + <> + {leading ?? (icon ? ( + + ) : null)} + + {label} + + {value !== undefined && value !== null ? ( + {value} + ) : null} + + ); + + const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className); + + if (!onClick) return
{body}
; + + return ( + + ); +}; + +type WorkStatusTone = 'default' | 'muted' | 'success' | 'error' | 'warning' | 'info'; + +const TONE_COLOR: Record, string> = { + success: 'var(--status-success)', + error: 'var(--status-error)', + warning: 'var(--status-warning)', + info: 'var(--status-info)', +}; + +export const WorkStatusValue: React.FC<{ + children: React.ReactNode; + tone?: WorkStatusTone; +}> = ({ children, tone = 'default' }) => ( + + {children} + +); + +/** + * Trailing control shaped like the PR badge: a status that is also the thing + * you press. Used where the state itself is the affordance — an MCP server + * asking for sign-in, a goal waiting to be resumed. + */ +export const WorkStatusRowAction: React.FC<{ + children: React.ReactNode; + onClick: () => void; + tone?: 'default' | 'warning' | 'error' | 'info'; + disabled?: boolean; + ariaLabel?: string; +}> = ({ children, onClick, tone = 'default', disabled, ariaLabel }) => { + const color = tone === 'default' ? undefined : TONE_COLOR[tone]; + return ( + + ); +}; + +export const WorkStatusPill: React.FC<{ + children: React.ReactNode; + color?: string; + background?: string; +}> = ({ children, color, background }) => ( + + {children} + +); + +/** Full-width callout for states that block the branch (merge, rebase, …). */ +export const WorkStatusCallout: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ + {children} +
+); + +/** Context-window fill, drawn under its row rather than inside it. */ +export const WorkStatusMeter: React.FC<{ percent: number; color: string }> = ({ percent, color }) => ( +
+
+
+); diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx new file mode 100644 index 00000000..a2370c02 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useUIStore } from '@/stores/useUIStore'; +import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + WORK_STATUS_SECTION_IDS, + WORK_STATUS_SECTION_LABEL_KEYS, + isWorkStatusSectionVisible, +} from './sections'; + +/** + * Which sections the work-status panel may show. + * + * Everything is on by default and the choice is stored as the *hidden* set, so + * a section added in a later release appears for everyone rather than staying + * invisible to whoever had saved settings before it existed. + */ +export const WorkStatusSectionsDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const hidden = useUIStore((state) => state.workStatusHiddenSections); + const setSectionVisible = useUIStore((state) => state.setWorkStatusSectionVisible); + + return ( + + + + {t('chat.workStatus.sections.dialogTitle')} + {t('chat.workStatus.sections.dialogDescription')} + + +
+ {WORK_STATUS_SECTION_IDS.map((sectionId) => ( + setSectionVisible(sectionId, checked)} + label={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])} + ariaLabel={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])} + /> + ))} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx new file mode 100644 index 00000000..f670efeb --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useAllLiveSessions, useAllSessionStatuses, useDirectorySync } from '@/sync/sync-context'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; +import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +const SECTION_ID = 'subagents'; + +/** + * Running subagents and, more importantly, their blockers: a permission request + * raised by a child session has no representation in the transcript, so this + * panel is the only place it becomes visible. + */ +export const WorkStatusSubagentsSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + const isMobile = useUIStore((state) => state.isMobile); + + const liveSessions = useAllLiveSessions(); + const statuses = useAllSessionStatuses(); + const children = React.useMemo( + () => (sessionId ? liveSessions.filter((candidate) => candidate.parentID === sessionId) : []), + [liveSessions, sessionId], + ); + + // One subscription covers every child: per-session hooks would multiply + // store subscriptions by the number of subagents. + const permissions = useDirectorySync(React.useCallback((state: State) => state.permission, [])); + const questions = useDirectorySync(React.useCallback((state: State) => state.question, [])); + + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const setSectionExpanded = useUIStore((state) => state.setWorkStatusSectionExpanded); + + // Subagents appearing where there were none is the one moment this section + // has something urgent to say, so it opens itself. Only on the empty→present + // edge: re-expanding on every count change would fight a user who just + // collapsed it. + const hadChildren = React.useRef(children.length > 0); + React.useEffect(() => { + const present = children.length > 0; + if (present && !hadChildren.current) setSectionExpanded(SECTION_ID, true); + hadChildren.current = present; + }, [children.length, setSectionExpanded]); + + // Same branch the transcript's Task tool takes: surfaces that cannot host an + // embedded panel navigate to the child session instead of opening a tab. + const openChildSession = React.useCallback((childId: string, label: string) => { + if (!directory) return; + if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) { + setCurrentSession(childId, directory); + return; + } + openContextPanelTab(directory, { + mode: 'chat', + dedupeKey: `session:${childId}`, + label, + readOnly: true, + }); + }, [directory, isMobile, openContextPanelTab, setCurrentSession]); + + useReportWorkStatusPresence('subagents', children.length > 0); + + if (children.length === 0) return null; + + const busyChildren = children.filter((child) => statuses[child.id]?.type === 'busy').length; + + return ( + 0 ? `${busyChildren}/${children.length}` : children.length} + > + {children.map((child) => { + const blocked = (permissions[child.id]?.length ?? 0) > 0; + const asked = (questions[child.id]?.length ?? 0) > 0; + const busy = statuses[child.id]?.type === 'busy'; + const label = child.title?.trim() || t('chat.workStatus.subagent.untitled'); + return ( + openChildSession(child.id, label) : undefined} + ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })} + label={label} + value={blocked ? ( + {t('chat.workStatus.subagent.needsPermission')} + ) : asked ? ( + {t('chat.workStatus.subagent.askedQuestion')} + ) : busy ? ( + {t('chat.workStatus.subagent.working')} + ) : ( + {t('chat.workStatus.subagent.done')} + )} + /> + ); + })} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx new file mode 100644 index 00000000..31d1e71f --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDirectorySync } from '@/sync/sync-context'; +import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; +import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; +import type { Todo } from '@opencode-ai/sdk/v2'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +const EMPTY_TODOS: Todo[] = []; + +/** + * Work first, then what is waiting, then what is done — the panel is read + * top-down for "what is happening", and a finished item never answers that. + * Unlike the composer's dropdown, completed items stay: this is a record of the + * session, not a queue to work through. + */ +const STATUS_RANK: Record = { + in_progress: 0, + pending: 1, + completed: 2, +}; + +/** Same icons the composer's todo dropdown uses, so one list does not read as two. */ +const statusIcon = (status: string): { name: 'record-circle' | 'checkbox-circle' | 'time'; color?: string } => { + if (status === 'in_progress') return { name: 'record-circle', color: 'var(--status-info)' }; + if (status === 'completed') return { name: 'checkbox-circle', color: 'var(--status-success)' }; + return { name: 'time' }; +}; + +export const WorkStatusTasksSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + + const liveTodos = useDirectorySync( + React.useCallback( + (state: State) => (sessionId ? state.todo[sessionId] ?? EMPTY_TODOS : EMPTY_TODOS), + [sessionId], + ), + ); + const persistedTodos = useTodosPersistStore( + React.useCallback( + (state) => (sessionId && directory ? state.getSessionTodos(directory, sessionId) : undefined), + [directory, sessionId], + ), + ); + // Live channel wins; persistence only restores context for a session whose + // todo events predate this client's connection. + const todos = liveTodos.length > 0 ? liveTodos : persistedTodos ?? EMPTY_TODOS; + + const visibleTodos = React.useMemo(() => { + const kept = todos + .map((todo, index) => ({ todo, index })) + .filter(({ todo }) => todo.status !== 'cancelled'); + // Stable within a rank: the agent's own ordering carries meaning, so only + // the status grouping is imposed on top of it. + return kept + .sort((left, right) => { + const rank = (STATUS_RANK[left.todo.status] ?? 1) - (STATUS_RANK[right.todo.status] ?? 1); + return rank !== 0 ? rank : left.index - right.index; + }) + .map(({ todo }) => todo); + }, [todos]); + + useReportWorkStatusPresence('tasks', visibleTodos.length > 0); + + if (visibleTodos.length === 0) return null; + + const doneCount = visibleTodos.filter((todo) => todo.status === 'completed').length; + + return ( + + {visibleTodos.map((todo, index) => { + const done = todo.status === 'completed'; + const icon = statusIcon(todo.status); + return ( + + +
+ + )} + muted={done} + label={{todo.content}} + /> +
+
+ {/* Rows truncate at this width; the tooltip is the only way to read + a long task in full. */} + + {todo.content} + +
+ ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx new file mode 100644 index 00000000..f35719e0 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { preloadProviderLogos } from '@/hooks/useProviderLogo'; +import { formatQuotaResetLabel, formatQuotaValueLabel } from '@/lib/quota'; +import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useUsageProviderGroups } from '@/components/usage/usageGroups'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { pickUsageHeadline } from './usageHeadline'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { WorkStatusRow, WorkStatusCollapsibleSection, WorkStatusValue } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { UsageWindow } from '@/types'; + +/** + * Provider rate limits. + * + * The mobile popover renders these as filled cards; that language does not + * survive here — the fills and their padding fight the panel's flat rows and + * cost roughly twice the height. Only the data is shared + * (`useUsageProviderGroups`); the presentation is the panel's own row + * vocabulary, with each provider as a quiet sub-heading. + * + * Sits above Subagents and MCP: a spent quota stops the work outright, so it + * belongs with the readouts that hold for the whole session rather than with + * whatever happens to be running. + */ + +const windowTone = (window: UsageWindow): 'default' | 'warning' | 'error' => { + const used = window.usedPercent; + if (typeof used !== 'number' || !Number.isFinite(used)) return 'default'; + if (used >= 80) return 'error'; + if (used >= 50) return 'warning'; + return 'default'; +}; + +export const WorkStatusUsageSection: React.FC = () => { + const { t } = useI18n(); + const groups = useUsageProviderGroups(); + const displayMode = useQuotaStore((state) => state.displayMode); + const isLoading = useQuotaStore((state) => state.isLoading); + const quotaResults = useQuotaStore((state) => state.results); + const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); + const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + + // Keeps the periodic refresh running while the panel is mounted. + useQuotaAutoRefresh(); + + // `useQuotaAutoRefresh` only schedules an interval — it never performs the + // first fetch. That was owned by the header dropdown's open handler, so the + // panel stayed empty until the user opened it. Kick off the initial load for + // any enabled provider that has not reported yet, background-gated so it + // cannot compete with chat bootstrap traffic. + React.useEffect(() => { + if (isLoading || dropdownProviderIds.length === 0) return; + const missingProvider = dropdownProviderIds.some( + (providerId) => !quotaResults.some((result) => result.providerId === providerId), + ); + if (!missingProvider) return; + void runBackgroundNetworkTask(() => fetchAllQuotas()); + }, [dropdownProviderIds, fetchAllQuotas, isLoading, quotaResults]); + + React.useEffect(() => { + if (groups.length === 0) return; + preloadProviderLogos(groups.map((group) => group.providerId)); + }, [groups]); + + useReportWorkStatusPresence('usage', groups.length > 0); + + if (groups.length === 0) return null; + + const modeLabel = displayMode === 'remaining' + ? t('header.services.remaining') + : t('header.services.used'); + + // Collapsed, the section shows the tightest quota of the provider the + // composer is pointed at — the number that decides whether the next turn + // lands. With no match it falls back to the display-mode label rather than + // showing some other provider's quota as if it were the active one. + const headline = pickUsageHeadline(groups, currentProviderId); + const headlineMetric = headline + ? formatQuotaValueLabel( + headline.row.window.valueLabel, + displayMode === 'remaining' ? headline.row.window.remainingPercent : headline.row.window.usedPercent, + ) + : null; + + return ( + + {isLoading ? : null} + {headline && headlineMetric && headlineMetric !== '-' ? ( + <> + {headline.row.label} + {headlineMetric} + + ) : modeLabel} + + )} + > + {groups.map((group) => ( + + } + label={group.providerName} + muted + value={group.status && group.rows.length === 0 ? ( + {group.status} + ) : undefined} + /> + {group.rows.map((row) => { + const displayPercent = displayMode === 'remaining' + ? row.window.remainingPercent + : row.window.usedPercent; + const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); + const resetLabel = formatQuotaResetLabel( + row.window.resetAt, + row.window.resetAfterFormatted ?? row.window.resetAtFormatted, + timeFormatPreference, + ); + return ( + + + {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} + + {resetLabel ? ( + {resetLabel} + ) : null} + + )} + value={metricLabel === '-' ? undefined : ( + {metricLabel} + )} + /> + ); + })} + + ))} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/contextUsage.test.ts b/packages/ui/src/components/chat/work-status/contextUsage.test.ts new file mode 100644 index 00000000..c2e0a6e4 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/contextUsage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { computeContextUsage, DEFAULT_CONTEXT_LIMIT } from './contextUsage'; + +const assistant = (tokens: Record, id = 'msg') => ({ id, role: 'assistant', tokens }); + +describe('computeContextUsage', () => { + test('sums every token bucket of the newest reporting assistant message', () => { + const usage = computeContextUsage( + [assistant({ input: 100, output: 20, reasoning: 5, cache: { read: 800, write: 75 } })], + 2000, + ); + expect(usage?.totalTokens).toBe(1000); + expect(usage?.percent).toBe(50); + }); + + test('reports the latest turn rather than a sum across turns', () => { + // Each assistant turn reports the whole window it saw, so adding them up + // would report several times the real fill. + const usage = computeContextUsage( + [ + assistant({ input: 400, output: 0, reasoning: 0 }, 'old'), + assistant({ input: 900, output: 0, reasoning: 0 }, 'new'), + ], + 1000, + ); + expect(usage?.totalTokens).toBe(900); + }); + + test('skips user messages and assistant turns that reported nothing', () => { + const usage = computeContextUsage( + [ + assistant({ input: 300, output: 0, reasoning: 0 }, 'real'), + assistant({ input: 0, output: 0, reasoning: 0 }, 'zeroed'), + { id: 'user', role: 'user' }, + ], + 1000, + ); + expect(usage?.totalTokens).toBe(300); + }); + + test('leaves the percentage unrounded', () => { + // Rounding here is what made the panel print "34.0%" against the header's + // "33.6%". + const usage = computeContextUsage([assistant({ input: 336, output: 0, reasoning: 0 })], 1000); + expect(usage?.percent.toFixed(1)).toBe('33.6'); + }); + + test('falls back to the default limit when the model exposes none', () => { + const usage = computeContextUsage([assistant({ input: 20_000, output: 0, reasoning: 0 })], 0); + expect(usage?.limit).toBe(DEFAULT_CONTEXT_LIMIT); + expect(usage?.percent).toBe(10); + }); + + test('returns null when no message carries usable tokens', () => { + expect(computeContextUsage([], 1000)).toBeNull(); + expect(computeContextUsage([{ id: 'u', role: 'user' }], 1000)).toBeNull(); + expect(computeContextUsage([assistant({ input: 0, output: 0, reasoning: 0 })], 1000)).toBeNull(); + }); + + test('tolerates partial token payloads', () => { + const usage = computeContextUsage([assistant({ input: 10 })], 100); + expect(usage?.totalTokens).toBe(10); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/contextUsage.ts b/packages/ui/src/components/chat/work-status/contextUsage.ts new file mode 100644 index 00000000..c30d57f6 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/contextUsage.ts @@ -0,0 +1,71 @@ +/** + * Context-window usage for a specific session. + * + * `useSessionUIStore.getContextUsage` cannot serve this panel. It reads + * `getSyncMessages(sessionId)` with **no directory**, which resolves to the + * *current* directory's child store, and it keys off the store's own + * `currentSessionId`. A session held by another directory — a worktree, or any + * moment right after a directory switch — therefore reads as "no messages" and + * the readout silently disappears while the header still shows a value. + * + * This computes the same quantity from messages the caller has already + * subscribed to for a known session and directory, so there is no hidden + * global read to race with. + */ + +type MessageTokens = { + input?: number; + output?: number; + reasoning?: number; + cache?: { read?: number; write?: number }; +}; + +type MessageLike = { + id?: string; + role?: string; + tokens?: MessageTokens; +}; + +type WorkStatusContextUsage = { + totalTokens: number; + /** Context limit actually used for the ratio, after the default fallback. */ + limit: number; + /** Unrounded, so the panel and the header cannot disagree by a rounding step. */ + percent: number; +}; + +/** The store's own fallback when a model exposes no context limit. */ +export const DEFAULT_CONTEXT_LIMIT = 200_000; + +const sumTokens = (tokens: MessageTokens): number => ( + (tokens.input ?? 0) + + (tokens.output ?? 0) + + (tokens.reasoning ?? 0) + + (tokens.cache?.read ?? 0) + + (tokens.cache?.write ?? 0) +); + +/** + * Usage from the newest assistant message that reported a non-zero token count. + * Each assistant turn reports the whole window it saw, so the latest one is the + * current fill — not a sum across turns. + */ +export const computeContextUsage = ( + messages: readonly MessageLike[], + contextLimit: number, +): WorkStatusContextUsage | null => { + if (messages.length === 0) return null; + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant' || !message.tokens) continue; + + const totalTokens = sumTokens(message.tokens); + if (totalTokens <= 0) continue; + + const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT; + return { totalTokens, limit, percent: (totalTokens / limit) * 100 }; + } + + return null; +}; diff --git a/packages/ui/src/components/chat/work-status/presence.tsx b/packages/ui/src/components/chat/work-status/presence.tsx new file mode 100644 index 00000000..0138e4f1 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/presence.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { PresenceContext } from './presenceContext'; + +/** + * Collects which sections rendered, so the panel can hide its card entirely + * when none did. See `presenceContext.ts` for why sections report rather than + * the panel deriving it. + */ +export const WorkStatusPresenceProvider: React.FC<{ + onChange: (count: number) => void; + children: React.ReactNode; +}> = ({ onChange, children }) => { + const presentRef = React.useRef(new Set()); + + const report = React.useCallback((id: string, present: boolean) => { + const set = presentRef.current; + const had = set.has(id); + if (present === had) return; + if (present) set.add(id); + else set.delete(id); + onChange(set.size); + }, [onChange]); + + return {children}; +}; diff --git a/packages/ui/src/components/chat/work-status/presenceContext.ts b/packages/ui/src/components/chat/work-status/presenceContext.ts new file mode 100644 index 00000000..cf40e83b --- /dev/null +++ b/packages/ui/src/components/chat/work-status/presenceContext.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +/** + * Whether any section actually rendered. + * + * Every section decides for itself that it has nothing to say and returns + * null, so the panel cannot know in advance whether it is empty — and an empty + * panel is a bordered card holding nothing but its settings icon, which reads + * as a fault. Re-deriving each section's emptiness at the panel level would + * mean duplicating every data source it reads, so sections report instead. + */ +export const PresenceContext = React.createContext<((id: string, present: boolean) => void) | null>(null); + +/** Call from a section with whether it rendered anything this pass. */ +export const useReportWorkStatusPresence = (id: string, present: boolean): void => { + const report = React.useContext(PresenceContext); + React.useEffect(() => { + report?.(id, present); + // Leaving the set on unmount, so a section that stops rendering entirely + // does not keep the panel alive. + return () => report?.(id, false); + }, [id, present, report]); +}; diff --git a/packages/ui/src/components/chat/work-status/sections.test.ts b/packages/ui/src/components/chat/work-status/sections.test.ts new file mode 100644 index 00000000..015f79ea --- /dev/null +++ b/packages/ui/src/components/chat/work-status/sections.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; +import { + WORK_STATUS_SECTION_IDS, + WORK_STATUS_SECTION_LABEL_KEYS, + isWorkStatusSectionVisible, + sanitizeWorkStatusHiddenSections, +} from './sections'; + +describe('section registry', () => { + test('every section has a label, and every label a section', () => { + // One list drives the panel and the dialog; a mismatch means a section the + // user cannot switch, or a switch for nothing. + expect(Object.keys(WORK_STATUS_SECTION_LABEL_KEYS).sort()) + .toEqual([...WORK_STATUS_SECTION_IDS].sort()); + }); +}); + +describe('isWorkStatusSectionVisible', () => { + test('everything is visible by default', () => { + // Storing the hidden set means a section added later is on for everyone, + // rather than invisible to whoever had settings saved before it existed. + expect(isWorkStatusSectionVisible([], 'usage')).toBe(true); + expect(isWorkStatusSectionVisible(undefined, 'usage')).toBe(true); + expect(isWorkStatusSectionVisible(null, 'usage')).toBe(true); + }); + + test('hides exactly the listed section', () => { + expect(isWorkStatusSectionVisible(['usage'], 'usage')).toBe(false); + expect(isWorkStatusSectionVisible(['usage'], 'tasks')).toBe(true); + }); +}); + +describe('sanitizeWorkStatusHiddenSections', () => { + test('keeps known ids and drops everything else', () => { + expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks'])) + .toEqual(['usage', 'tasks']); + }); + + test('deduplicates', () => { + expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']); + }); + + test('treats a non-array payload as no preference', () => { + expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]); + expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]); + expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/sections.ts b/packages/ui/src/components/chat/work-status/sections.ts new file mode 100644 index 00000000..358d4338 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/sections.ts @@ -0,0 +1,59 @@ +import type { I18nKey } from '@/lib/i18n/messages/en'; + +/** + * Every section the work-status panel can render, in display order. + * + * One list drives both the panel and its settings dialog, so a section cannot + * exist in the panel without being switchable, or appear in the dialog without + * existing. + * + * The ids are persisted in user settings — renaming one silently resets that + * user's choice for it. + */ +export const WORK_STATUS_SECTION_IDS = [ + 'session', + 'repository', + 'usage', + 'subagents', + 'tasks', + 'mcp', + 'pinned', + 'contextSources', +] as const; + +type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number]; + +export const WORK_STATUS_SECTION_LABEL_KEYS: Record = { + session: 'chat.workStatus.section.session', + repository: 'chat.workStatus.section.repository', + usage: 'chat.workStatus.section.usage', + subagents: 'chat.workStatus.section.subagents', + tasks: 'chat.workStatus.section.tasks', + mcp: 'chat.workStatus.section.mcp', + pinned: 'chat.workStatus.section.pinned', + contextSources: 'chat.workStatus.section.contextBreakdown', +}; + +const KNOWN_IDS = new Set(WORK_STATUS_SECTION_IDS); + +const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId => + typeof value === 'string' && KNOWN_IDS.has(value); + +/** + * Hidden sections are stored, not visible ones: everything is on by default, so + * an empty list means "the user has changed nothing" and a section added later + * appears without touching anyone's saved settings. + */ +export const isWorkStatusSectionVisible = ( + hidden: readonly string[] | null | undefined, + id: WorkStatusSectionId, +): boolean => !hidden?.includes(id); + +export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + for (const entry of value) { + if (isWorkStatusSectionId(entry)) seen.add(entry); + } + return [...seen]; +}; diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.test.ts b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts new file mode 100644 index 00000000..998808a0 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { pickUsageHeadline, resolveQuotaProviderId } from './usageHeadline'; +import type { UsageProviderGroup } from '@/components/usage/usageGroups'; + +const HOUR = 3600; + +const window = (windowSeconds: number | null) => ({ + usedPercent: 10, + remainingPercent: 90, + windowSeconds, + resetAfterSeconds: null, + resetAt: null, + resetAtFormatted: null, + resetAfterFormatted: null, +}); + +const group = (providerId: string, rows: Array<{ key: string; label: string; subtitle?: string; seconds: number | null }>): UsageProviderGroup => ({ + providerId: providerId as UsageProviderGroup['providerId'], + providerName: providerId, + status: null, + rows: rows.map((row) => ({ + key: row.key, + label: row.label, + subtitle: row.subtitle, + window: window(row.seconds), + })), +}); + +describe('resolveQuotaProviderId', () => { + test('passes through ids that already match a quota provider', () => { + expect(resolveQuotaProviderId('opencode-go')).toBe('opencode-go'); + }); + + test('maps the known divergences', () => { + expect(resolveQuotaProviderId('openai')).toBe('codex'); + expect(resolveQuotaProviderId('anthropic')).toBe('claude'); + }); + + test('is case and whitespace tolerant, and rejects empties', () => { + expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex'); + expect(resolveQuotaProviderId('')).toBeNull(); + expect(resolveQuotaProviderId(null)).toBeNull(); + }); +}); + +describe('pickUsageHeadline', () => { + const groups = [ + group('codex', [{ key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }]), + group('opencode-go', [ + { key: 'm', label: 'Monthly Limit', seconds: 30 * 24 * HOUR }, + { key: 'h', label: '5-Hour', seconds: 5 * HOUR }, + { key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ]), + ]; + + test('picks the shortest window of the matching provider', () => { + // The tightest bucket is the one that decides whether the next turn lands. + expect(pickUsageHeadline(groups, 'opencode-go')?.row.label).toBe('5-Hour'); + }); + + test('resolves the provider through the alias table', () => { + expect(pickUsageHeadline(groups, 'openai')?.group.providerId).toBe('codex'); + }); + + test('returns null when no group matches the composer provider', () => { + // Showing another provider's quota would read as the active one. + expect(pickUsageHeadline(groups, 'mistral')).toBeNull(); + expect(pickUsageHeadline(groups, null)).toBeNull(); + }); + + test('ignores model-scoped rows while any provider-level row exists', () => { + const scoped = [group('zai-coding-plan', [ + { key: 'model', label: '5-Hour', subtitle: 'GLM-5', seconds: 5 * HOUR }, + { key: 'provider', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ])]; + expect(pickUsageHeadline(scoped, 'zai-coding-plan')?.row.label).toBe('Weekly Limit'); + }); + + test('falls back to a durationless row when nothing reports a window', () => { + const balances = [group('codex', [{ key: 'credits', label: 'Credits Balance', seconds: null }])]; + expect(pickUsageHeadline(balances, 'codex')?.row.label).toBe('Credits Balance'); + }); + + test('prefers any real window over a durationless row', () => { + const mixed = [group('codex', [ + { key: 'credits', label: 'Credits Balance', seconds: null }, + { key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ])]; + expect(pickUsageHeadline(mixed, 'codex')?.row.label).toBe('Weekly Limit'); + }); + + test('returns null for a matched provider that reported no rows', () => { + expect(pickUsageHeadline([group('codex', [])], 'codex')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.ts b/packages/ui/src/components/chat/work-status/usageHeadline.ts new file mode 100644 index 00000000..ad80e553 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/usageHeadline.ts @@ -0,0 +1,66 @@ +import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usageGroups'; + +/** + * Picking the one quota worth showing while the Usage section is collapsed. + * + * The interesting limit is the one that runs out first, which is the shortest + * window a provider reports — a 5-hour bucket says more about whether the next + * turn will land than a monthly one. Rows without a window duration (credit + * balances, tool counters) are kept only as a last resort, since they never + * answer "can I keep working right now". + */ + +/** + * Quota provider ids mostly match OpenCode provider ids; these are the ones + * that do not. Unmatched providers simply produce no headline. + */ +const QUOTA_PROVIDER_ALIASES = new Map([ + ['openai', 'codex'], + ['chatgpt', 'codex'], + ['anthropic', 'claude'], + ['gemini', 'google'], +]); + +const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase(); + +export const resolveQuotaProviderId = (modelProviderId: string | null | undefined): string | null => { + const normalized = normalize(modelProviderId); + if (!normalized) return null; + return QUOTA_PROVIDER_ALIASES.get(normalized) ?? normalized; +}; + +/** + * Shortest reported window for the provider the composer is pointed at. + * + * Returns null when nothing matches — the section then falls back to its + * display-mode label rather than showing a quota belonging to some other + * provider, which would read as the active one. + */ +export const pickUsageHeadline = ( + groups: readonly UsageProviderGroup[], + modelProviderId: string | null | undefined, +): { group: UsageProviderGroup; row: UsageLimitRow } | null => { + const quotaProviderId = resolveQuotaProviderId(modelProviderId); + if (!quotaProviderId) return null; + + const group = groups.find((candidate) => normalize(candidate.providerId) === quotaProviderId); + if (!group || group.rows.length === 0) return null; + + // Provider-level rows only: a model-scoped row describes one model, not the + // provider the composer is pointed at. + const providerRows = group.rows.filter((row) => !row.subtitle); + const rows = providerRows.length > 0 ? providerRows : group.rows; + + let best: UsageLimitRow | null = null; + let bestSeconds = Number.POSITIVE_INFINITY; + for (const row of rows) { + const seconds = row.window.windowSeconds; + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) continue; + if (seconds < bestSeconds) { + best = row; + bestSeconds = seconds; + } + } + + return { group, row: best ?? rows[0] }; +}; diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts new file mode 100644 index 00000000..ca502ab2 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts @@ -0,0 +1,329 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +type PanelState = { + isOpen: boolean; + tabs: { id: string; mode: string }[]; + activeTabId: string | null; +}; + +let panelByDirectory: Record = {}; +let panelEnabled = true; + +mock.module('@/stores/useUIStore', () => ({ + useUIStore: (selector: (state: unknown) => unknown) => + selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }), +})); + +mock.module('@/lib/pathNormalization', () => ({ + normalizePath: (value?: string | null) => value ?? null, +})); + +const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import( + './useWorkStatusVisibility' +); + +/** Elements the stubbed ResizeObserver was asked to observe, in order. */ +let observed: unknown[] = []; +let notify: ((entries: { contentRect: { width: number } }[]) => void) | null = null; + +class StubResizeObserver { + constructor(callback: (entries: { contentRect: { width: number } }[]) => void) { + notify = callback; + } + + observe(element: unknown) { + observed.push(element); + } + + disconnect() { + notify = null; + } +} + +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + const documentStub: Record = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: documentStub, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + documentStub.documentElement = container; + documentStub.body = container; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('ResizeObserver', StubResizeObserver); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean }; + +/** + * Renders the hook with a stand-in row node, attached through the returned + * callback ref exactly as the real tree does. + */ +const renderVisibility = (args: Args, rowWidth: number) => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + // `closest` returns null here, so the hook falls back to the row itself — + // the fallback path is what these cases exercise. + const rowNode = { + getBoundingClientRect: () => ({ width: rowWidth }), + closest: () => null, + } as unknown as HTMLDivElement; + const result = { visible: false, fits: false }; + + const Probe: React.FC = () => { + const { rowRef, visible, fits } = useWorkStatusVisibility(args); + result.visible = visible; + result.fits = fits; + React.useLayoutEffect(() => { + rowRef(rowNode); + return () => rowRef(null); + }, [rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + return { + result, + rowNode, + teardown: () => { + act(() => { root.unmount(); }); + dom.restore(); + }, + }; +}; + +beforeEach(() => { + panelByDirectory = {}; + panelEnabled = true; + observed = []; + notify = null; +}); + +afterEach(() => { + observed = []; + notify = null; +}); + +describe('useWorkStatusVisibility', () => { + test('shows the panel when the row can afford both columns', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + teardown(); + }); + + test('hides the panel when the row cannot afford both columns', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED - 1, + ); + expect(result.visible).toBe(false); + teardown(); + }); + + test('prefers the marked chat area over the row it was handed', () => { + // The row is what the context panel squeezes, over an animation. Measuring + // it made the panel reappear only once that number caught up, so the chat + // widened first and narrowed again afterwards. + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const chatArea = { getBoundingClientRect: () => ({ width: REQUIRED }) }; + const rowNode = { + getBoundingClientRect: () => ({ width: 0 }), + closest: () => chatArea, + } as unknown as HTMLDivElement; + const result = { visible: false }; + + const Probe: React.FC = () => { + const { rowRef, visible } = useWorkStatusVisibility({ + directory: '/repo', + isMobile: false, + isVSCode: false, + }); + result.visible = visible; + React.useLayoutEffect(() => { + rowRef(rowNode); + return () => rowRef(null); + }, [rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + expect(observed).toEqual([chatArea]); + expect(result.visible).toBe(true); + + act(() => { root.unmount(); }); + dom.restore(); + }); + + test('measures a container the panel cannot resize, never the chat column', () => { + // The measured element must not depend on whether the panel is showing: + // otherwise hiding the panel widens it and re-shows the panel, forever. + // In the app this is the chat area (chat + context panel); here `closest` + // finds nothing, so the hook falls back to the row it was given. + const { rowNode, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(observed).toHaveLength(1); + expect(observed[0]).toBe(rowNode); + teardown(); + }); + + test('reacts to a live resize across the threshold', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + act(() => { notify?.([{ contentRect: { width: REQUIRED - 40 } }]); }); + expect(result.visible).toBe(false); + act(() => { notify?.([{ contentRect: { width: REQUIRED + 200 } }]); }); + expect(result.visible).toBe(true); + teardown(); + }); + + test('yields to an open context panel while still measuring the row', () => { + // Measurement continues so the panel can come back in the same commit that + // reveals it. Stopping cost a frame: closing the context panel widened the + // chat, and only then did the panel reappear and narrow it again. + panelByDirectory = { + '/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' }, + }; + const { result, rowNode, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(false); + expect(observed).toEqual([rowNode]); + teardown(); + }); + + test('ignores an open context panel that has no resolvable tab', () => { + // ContextPanel renders nothing in that state, so it displaces nothing. + panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } }; + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + teardown(); + }); + + test('measures a row that attaches after the first render', () => { + // Regression: with an object ref the measuring effect read `.current` + // once, found nothing when the row mounted late, and only recovered when + // some unrelated dependency changed — in practice, opening and closing the + // context panel. The panel must appear as soon as the row exists. + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const rowNode = { + getBoundingClientRect: () => ({ width: REQUIRED }), + closest: () => null, + } as unknown as HTMLDivElement; + const result = { visible: false }; + let attach: (value: boolean) => void = () => undefined; + + const Probe: React.FC = () => { + const [attached, setAttached] = React.useState(false); + const { rowRef, visible } = useWorkStatusVisibility({ + directory: '/repo', + isMobile: false, + isVSCode: false, + }); + result.visible = visible; + attach = setAttached; + React.useLayoutEffect(() => { + if (attached) rowRef(rowNode); + }, [attached, rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + expect(result.visible).toBe(false); + + act(() => { attach(true); }); + expect(result.visible).toBe(true); + + act(() => { root.unmount(); }); + dom.restore(); + }); + + test('stays hidden when the user switched the panel off, but still reports the fit', () => { + // The header offers the panel as an overlay when layout refuses it, so it + // needs the two answers apart: whether the user wants it, and whether + // there is room for it. + panelEnabled = false; + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED * 2, + ); + expect(result.visible).toBe(false); + expect(result.fits).toBe(true); + teardown(); + }); + + test('reports no fit when the row is too narrow, whatever the switch says', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED - 1, + ); + expect(result.fits).toBe(false); + expect(result.visible).toBe(false); + teardown(); + }); + + test('stays hidden on mobile and in VS Code regardless of width', () => { + const mobile = renderVisibility( + { directory: '/repo', isMobile: true, isVSCode: false }, + REQUIRED * 2, + ); + expect(mobile.result.visible).toBe(false); + mobile.teardown(); + + observed = []; + const vscode = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: true }, + REQUIRED * 2, + ); + expect(vscode.result.visible).toBe(false); + vscode.teardown(); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts new file mode 100644 index 00000000..541e4ae7 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts @@ -0,0 +1,116 @@ +import React from 'react'; +import { useUIStore } from '@/stores/useUIStore'; +import { normalizePath } from '@/lib/pathNormalization'; + +/** + * Fixed panel width. The panel is not user-resizable: it is an object inside + * the chat rather than a docked pane, so it has no resizer and no persisted + * width. + */ +export const WORK_STATUS_PANEL_WIDTH = 300; + +/** + * Minimum width the message column must keep for itself. Below this the panel + * yields — a squeezed transcript costs more than the status it displaces. + */ +const WORK_STATUS_MIN_CHAT_WIDTH = 560; + +/** The card's own horizontal margins (`ml-2` + `mr-4`). */ +const WORK_STATUS_PANEL_GUTTER = 8 + 16; + +/** Row width below which the panel gives its space back to the transcript. */ +export const WORK_STATUS_REQUIRED_ROW_WIDTH = + WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH; + +type Options = { + directory: string | null | undefined; + isMobile: boolean; + isVSCode: boolean; +}; + +type Result = { + /** Layout can host the panel inline, regardless of the user's switch. */ + fits: boolean; + /** + * Attach to the flex row that contains the chat column and the panel. + * + * A callback ref, not an object ref: an object ref gives no signal when the + * node attaches, so a measuring effect that reads `.current` would silently + * observe nothing whenever the row mounts after the effect first ran, and + * would only recover on the next unrelated dependency change. + */ + rowRef: (node: HTMLDivElement | null) => void; + visible: boolean; +}; + +/** + * Decides whether the work-status panel may occupy space inside the chat. + * + * The width test measures the ROW (chat column + panel), never the chat column + * alone. The chat column's width is an output of this decision: hiding the + * panel widens it, which would re-satisfy a chat-width test and re-show the + * panel, oscillating forever. The row width is independent of the panel, so it + * is the only stable input. + */ +export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => { + const [rowNode, setRowNode] = React.useState(null); + const [rowWidth, setRowWidth] = React.useState(null); + const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []); + + const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]); + + // Mirrors ContextPanel's own derivation: a panel with `isOpen` but no + // resolvable active tab renders nothing, and must not displace this panel. + const contextPanelOpen = useUIStore( + React.useCallback( + (state) => { + const panel = directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined; + if (!panel?.isOpen) return false; + const activeTab = panel.tabs.find((tab) => tab.id === panel.activeTabId) + ?? panel.tabs[panel.tabs.length - 1] + ?? null; + return Boolean(activeTab); + }, + [directoryKey], + ), + ); + + // The user's own switch, persisted to server settings, gates everything + // before layout is even measured. + const panelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + + // Split from the switch: a narrow chat is a layout fact, and the header needs + // it to offer the panel as an overlay instead of pretending it is off. + const layoutAllows = !isMobile && !isVSCode && !contextPanelOpen; + + // Measures the chat AREA — the container holding the chat and the context + // panel together — not the chat row inside it. + // + // The row is what the context panel squeezes, and it squeezes it over a + // 200ms animation. Measuring the row therefore reported a width that was + // still catching up while the context panel collapsed, so this panel only + // reappeared once that number crossed the threshold: the chat widened first + // and narrowed again afterwards. The chat area's width does not move when + // the context panel opens, so the reading is correct the instant it closes. + // + // It is also the stable input the oscillation argument needs: this panel's + // own visibility cannot change the width being measured. + React.useEffect(() => { + if (!rowNode || typeof ResizeObserver === 'undefined') return undefined; + + const measured = rowNode.closest('[data-chat-area]') ?? rowNode; + setRowWidth(measured.getBoundingClientRect().width); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setRowWidth(entry.contentRect.width); + }); + observer.observe(measured); + return () => observer.disconnect(); + }, [rowNode]); + + const fits = layoutAllows && rowWidth !== null && rowWidth >= WORK_STATUS_REQUIRED_ROW_WIDTH; + const visible = panelEnabled && fits; + + return { rowRef, visible, fits }; +}; diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 76e53af9..8cd3022c 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -23,7 +23,6 @@ import { desktopOpenNewWindowAtUrl, desktopOpenNewWindowForHost, getDesktopHostApiUrl, - locationMatchesHost, normalizeHostUrl, probeRelayDesktopHost, redactSensitiveUrl, @@ -31,10 +30,17 @@ import { type DesktopHost, type HostProbeResult, } from '@/lib/desktopHosts'; +import { + LOCAL_HOST_ID, + buildLocalDesktopHost, + getLocalDesktopOrigin, + resolveCurrentDesktopHost, + runtimeKeyForDesktopHost, +} from '@/lib/desktopCurrentHost'; import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore'; import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; -import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopSshConnect, desktopSshDisconnect, @@ -43,15 +49,9 @@ import { type DesktopSshInstanceStatus, } from '@/lib/desktopSsh'; -const LOCAL_HOST_ID = 'local'; const SSH_CONNECT_TIMEOUT_MS = 90_000; const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled'; -const runtimeKeyForHost = (host: DesktopHost): string => { - if (host.id === LOCAL_HOST_ID) return 'local'; - return `host:${host.id}`; -}; - type HostStatus = { status: HostProbeResult['status']; latencyMs: number; @@ -83,11 +83,6 @@ const toNavigationUrl = (rawUrl: string): string => { } }; -const getLocalOrigin = (): string => { - if (typeof window === 'undefined') return ''; - return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; -}; - const getLocalClientToken = async (): Promise => { if (!isElectronShell()) return ''; return desktopLocalClientTokenGet().catch(() => ''); @@ -236,67 +231,6 @@ const waitForSshReady = async ( throw new Error('Timed out waiting for SSH connection'); }; -const buildLocalHost = (localOrigin?: string | null): DesktopHost => ({ - id: LOCAL_HOST_ID, - label: 'Local', - url: localOrigin || getLocalOrigin(), -}); - -const resolveCurrentHost = (hosts: DesktopHost[]) => { - const currentHref = typeof window === 'undefined' ? '' : window.location.href; - const localOrigin = hosts.find((host) => host.id === LOCAL_HOST_ID)?.url || getLocalOrigin(); - const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); - const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin; - const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref; - - // Relay hosts share the window origin as their (virtual) API base, so URL - // matching can't distinguish them — identify the active relay host by its - // stable runtime key instead. - const activeRuntimeKey = getRuntimeKey(); - const relayMatch = hosts.find((h) => h.relay && runtimeKeyForHost(h) === activeRuntimeKey); - if (relayMatch) { - return { id: relayMatch.id, label: relayMatch.label, url: relayMatch.url }; - } - - if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - const runtimeMatch = hosts.find((h) => { - return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(h)) : false; - }); - - if (runtimeMatch) { - return { - id: runtimeMatch.id, - label: runtimeMatch.label, - url: normalizeHostUrl(getDesktopHostApiUrl(runtimeMatch)) || getDesktopHostApiUrl(runtimeMatch), - }; - } - - if (currentHref && locationMatchesHost(currentHref, localOrigin)) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - const match = hosts.find((h) => { - return currentHref ? locationMatchesHost(currentHref, h.url) : false; - }); - - if (match) { - return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url }; - } - - if (currentHref.startsWith('openchamber-ui://')) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - return { - id: 'custom', - label: redactSensitiveUrl(normalizedCurrent || 'Instance'), - url: normalizedCurrent, - }; -}; - type DesktopHostSwitcherDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -342,7 +276,7 @@ export function DesktopHostSwitcherDialog({ error: null, }); const [error, setError] = React.useState(''); - const [localOrigin, setLocalOrigin] = React.useState(() => getLocalOrigin()); + const [localOrigin, setLocalOrigin] = React.useState(() => getLocalDesktopOrigin()); const [editingId, setEditingId] = React.useState(null); const [editLabel, setEditLabel] = React.useState(''); @@ -352,7 +286,7 @@ export function DesktopHostSwitcherDialog({ const sshSwitchTokenRef = React.useRef(0); const allHosts = React.useMemo(() => { - const local = buildLocalHost(localOrigin); + const local = buildLocalDesktopHost(localOrigin); const normalizedRemote = configHosts.map((h) => ({ ...h, url: normalizeHostUrl(h.url) || h.url, @@ -366,7 +300,7 @@ export function DesktopHostSwitcherDialog({ const current = React.useMemo(() => { void runtimeEndpointEpoch; - return resolveCurrentHost(allHosts); + return resolveCurrentDesktopHost(allHosts); }, [allHosts, runtimeEndpointEpoch]); const currentDefaultLabel = React.useMemo(() => { const id = defaultHostId || LOCAL_HOST_ID; @@ -525,7 +459,7 @@ export function DesktopHostSwitcherDialog({ switchRuntimeEndpoint({ apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', clientToken: host.clientToken || null, - runtimeKey: runtimeKeyForHost(host), + runtimeKey: runtimeKeyForDesktopHost(host), relay, }); // On the relay: learn the server's current LAN address in the background @@ -551,7 +485,7 @@ export function DesktopHostSwitcherDialog({ if (cached.via === 'relay' && host.relay) { activateRelay(host.relay); } else if (apiOrigin) { - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForDesktopHost(host) }); } else if (host.relay) { activateRelay(host.relay); } @@ -590,7 +524,7 @@ export function DesktopHostSwitcherDialog({ if (transport === 'relay' && host.relay) { activateRelay(host.relay, relayProbeTunnel); } else { - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForDesktopHost(host) }); } onHostSwitched?.(); setSwitchingHostId(null); diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index c1b957b7..769b58cd 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -150,6 +150,7 @@ export const iconSpriteData = { "link-unlink-m": ``, "list-check-2": ``, "list-check-3": ``, + "list-indefinite": ``, "list-unordered": ``, "loader": ``, "loader-4": ``, diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index f7f5ce0d..6b3c147a 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -120,7 +120,14 @@ const ContextPanelRailItem: React.FC = ({ ) : displayBadgeCount ? ( @@ -152,6 +159,7 @@ export const ContextPanelRail: React.FC = () => { const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''; const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); + const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); @@ -286,7 +294,9 @@ export const ContextPanelRail: React.FC = () => { const label = t(surface.labelKey); // Git shows a numeric badge instead of the old activity dot. // Other surfaces never inherit git's changed-files signal. - const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0; + // The work-status panel reports the same count in words a few + // pixels away; two live counts for one fact is one too many. + const gitChangedCount = surface.id === 'git' && !workStatusPanelVisible ? changedFilesCount : 0; const badgeCount = gitChangedCount > 0 ? gitChangedCount : null; return ( >; refreshCurrentInstanceLabel: () => Promise; - desktopServicesTab: 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab: React.Dispatch>; - quotaResultsLength: number; - fetchAllQuotas: () => Promise; - servicesTabItems: SortableTabsStripItem[]; - quotaLastUpdated: number | null; - quotaDisplayMode: 'usage' | 'remaining'; - quotaDisplayTabItems: SortableTabsStripItem[]; - handleDisplayModeChange: (mode: 'usage' | 'remaining') => Promise; - handleUsageRefresh: () => void; - isQuotaLoading: boolean; - isUsageRefreshSpinning: boolean; - hasRateLimits: boolean; - rateLimitGroups: RateLimitGroup[]; - expandedFamilies: Record; - toggleFamilyExpanded: (providerId: string, familyId: string) => void; shortcutLabel: (actionId: string) => string; - showDevShutdown: boolean; - isDevShutdownInFlight: boolean; - onDevShutdown: () => Promise; remoteUpdateInfo: UpdateInfo | null; remoteUpdateChecking: boolean; remoteUpdateError: string | null; onOpenRemoteUpdate: () => void; - showPredValues: boolean; - timeFormatPreference: TimeFormatPreference; }; const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ @@ -305,32 +288,11 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ isDesktopServicesOpen, setIsDesktopServicesOpen, refreshCurrentInstanceLabel, - desktopServicesTab, - setDesktopServicesTab, - quotaResultsLength, - fetchAllQuotas, - servicesTabItems, - quotaLastUpdated, - quotaDisplayMode, - quotaDisplayTabItems, - handleDisplayModeChange, - handleUsageRefresh, - isQuotaLoading, - isUsageRefreshSpinning, - hasRateLimits, - rateLimitGroups, - expandedFamilies, - toggleFamilyExpanded, shortcutLabel, - showDevShutdown, - isDevShutdownInFlight, - onDevShutdown, remoteUpdateInfo, remoteUpdateChecking, remoteUpdateError, onOpenRemoteUpdate, - showPredValues, - timeFormatPreference, }: DesktopServicesMenuProps) { const { t } = useI18n(); return ( @@ -340,9 +302,6 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ setIsDesktopServicesOpen(open); if (open) { void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResultsLength === 0) { - void fetchAllQuotas(); - } } }} > @@ -359,7 +318,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8' )} > - + {isDesktopApp ? ( {compactCurrentInstanceLabel} ) : null} @@ -368,16 +327,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({

- {isDesktopApp - ? t('header.services.tooltip.currentInstanceWithShortcuts', { - current: currentInstanceLabel, - toggle: shortcutLabel('toggle_services_menu'), - nextTab: shortcutLabel('cycle_services_tab'), - }) - : t('header.services.tooltip.servicesWithShortcuts', { - toggle: shortcutLabel('toggle_services_menu'), - nextTab: shortcutLabel('cycle_services_tab'), - })} + {t('header.services.tooltip.currentInstance', { + current: currentInstanceLabel, + toggle: shortcutLabel('toggle_services_menu'), + })}

@@ -385,28 +338,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ align="end" className="w-[min(27rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto bg-[var(--surface-elevated)] p-0" > -
-
- { - const value = tabID as 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab(value); - if (value === 'usage' && quotaResultsLength === 0) { - void fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
-
- - {isDesktopApp && desktopServicesTab === 'instance' ? ( + {isDesktopApp ? (
{!currentInstanceIsLocal ? (
@@ -435,185 +367,13 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ ) : null} {}} onHostSwitched={() => setIsDesktopServicesOpen(false)} />
) : null} - {desktopServicesTab === 'mcp' ? ( - - ) : null} - - {desktopServicesTab === 'usage' ? ( -
-
-
- {t('header.services.rateLimits')} - {formatTime(quotaLastUpdated, timeFormatPreference)} -
-
-
- void handleDisplayModeChange(tabID as 'usage' | 'remaining')} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - className="h-full" - /> -
- -
-
- - {!hasRateLimits ? ( -
- {t('header.services.noRateLimits')} -
- ) : null} - - {/* One elevated card per provider (same card language as the mobile - usage popover) instead of a flat run of divider-separated rows. */} -
- {rateLimitGroups.map((group) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - return ( -
-
- - {group.providerName} -
- {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- {group.error ?? t('header.services.noRateLimitsReported')} -
- ) : ( -
- {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); - return ( -
-
-
- {formatWindowLabel(label)} - {resetLabel ? ( - - {resetLabel} - - ) : null} -
- - {metricLabel === '-' ? '' : metricLabel} - -
- - {paceInfo && showPredValues ? : null} -
- ); - })} - {group.modelFamilies && group.modelFamilies.length > 0 ? ( -
- {group.modelFamilies.map((family) => { - const familyKey = family.familyId ?? 'other'; - const isExpanded = providerExpandedFamilies.includes(familyKey); - return ( - toggleFamilyExpanded(group.providerId, familyKey)} - > - - {family.familyLabel} - {isExpanded ? : } - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - return ( -
-
- {getDisplayModelName(modelName)} - - {metricLabel === '-' ? '' : metricLabel} - -
- - {paceInfo && showPredValues ? : null} -
- ); - })} -
-
-
- ); - })} -
- ) : null} -
- )} -
- ); - })} -
-
- ) : null} - - {showDevShutdown ? ( - <> -
-
- { - void onDevShutdown(); - }} - > - {t('header.services.shutdownDev')} - -
- - ) : null} ); @@ -740,7 +500,6 @@ export const Header: React.FC = ({ const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); - const [isDevShutdownInFlight, setIsDevShutdownInFlight] = React.useState(false); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); @@ -894,18 +653,38 @@ export const Header: React.FC = ({ const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(null); const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]); - const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>( - isDesktopApp ? 'instance' : 'usage' - ); const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage'); - useEffect(() => { - if (!isDesktopApp && desktopServicesTab === 'instance') { - setDesktopServicesTab('usage'); - } - }, [desktopServicesTab, isDesktopApp]); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0; + // While the work-status panel is on screen it already reports the project, + // the branch and the context fill — three paces away in the same window. + // These yield to it rather than saying the same thing twice, and return the + // moment the panel is switched off or squeezed out by a narrow chat. + const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); + const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + const setWorkStatusPanelEnabled = useUIStore((state) => state.setWorkStatusPanelEnabled); + const workStatusPanelFits = useUIStore((state) => state.workStatusPanelFits); + const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen); + const setWorkStatusOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen); + + // Two meanings for one button. With room beside the chat it switches the + // panel on and off. Without room it cannot be shown inline at all, so it + // reads as off and opens the panel over the chat instead — the stored + // preference is left alone, so the panel comes back on its own once the + // window is wide enough again. + const workStatusPanelShownInline = workStatusPanelEnabled && workStatusPanelFits; + const workStatusToggleActive = workStatusPanelShownInline || workStatusOverlayOpen; + const handleWorkStatusToggle = React.useCallback(() => { + if (workStatusPanelEnabled && !workStatusPanelFits) { + setWorkStatusOverlayOpen(!workStatusOverlayOpen); + return; + } + setWorkStatusPanelEnabled(!workStatusPanelEnabled); + }, [setWorkStatusOverlayOpen, setWorkStatusPanelEnabled, workStatusOverlayOpen, workStatusPanelEnabled, workStatusPanelFits]); + const showDesktopHeaderContextUsage = !isVSCode + && !workStatusPanelVisible + && activeMainTab === 'chat' + && !!stableDesktopContextUsage + && stableDesktopContextUsage.totalTokens > 0; const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0 ? Math.min(999, (stableDesktopContextUsage.totalTokens / stableDesktopContextUsage.contextLimit) * 100) : 0; @@ -923,26 +702,19 @@ export const Header: React.FC = ({ } setCurrentInstanceIsLocal(false); + // Same resolution the host switcher's own header uses, so the button and + // the panel it opens can never disagree about which instance this is. const cfg = await desktopHostsGet(); - const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; - const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); + const localOrigin = getLocalDesktopOrigin(); + const resolved = resolveCurrentDesktopHost([buildLocalDesktopHost(localOrigin), ...cfg.hosts]); - if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) { + if (resolved.id === LOCAL_HOST_ID) { setCurrentInstanceLabel('Local'); setCurrentInstanceIsLocal(true); return; } - const match = cfg.hosts.find((host) => { - return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false; - }); - - if (match?.label?.trim()) { - setCurrentInstanceLabel(redactSensitiveUrl(match.label.trim())); - return; - } - - setCurrentInstanceLabel('Instance'); + setCurrentInstanceLabel(redactSensitiveUrl(resolved.label.trim() || 'Instance')); } catch { setCurrentInstanceLabel('Local'); setCurrentInstanceIsLocal(true); @@ -951,6 +723,11 @@ export const Header: React.FC = ({ useEffect(() => { void refreshCurrentInstanceLabel(); + // Switching instances does not remount the header, so without this the + // button would keep naming the instance the window left behind. + return subscribeRuntimeEndpointChanged(() => { + void refreshCurrentInstanceLabel(); + }); }, [refreshCurrentInstanceLabel]); const checkRemoteInstanceUpdate = React.useCallback(async () => { @@ -1306,6 +1083,12 @@ export const Header: React.FC = ({ const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + // Whether the title carries a second line under it. Hoisted because the + // session menu's vertical alignment depends on the same answer. + const showHeaderMetaRow = !workStatusPanelVisible + && Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)); + + const currentSessionTitle = React.useMemo(() => { if (!currentSessionId) { return activeProjectLabel ?? 'OpenChamber'; @@ -1948,93 +1731,17 @@ export const Header: React.FC = ({ } }, [activeMainTab, isMobile, setActiveMainTab]); + // Desktop keeps instances only: quota and MCP now live in the work-status + // panel, which reports them per session rather than per window. The mobile + // menu below is untouched — it has no panel to defer to. const servicesTabs = React.useMemo(() => { const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = []; if (isDesktopApp) { base.push({ value: 'instance', label: t('layout.services.instance'), icon: }); } - base.push( - { value: 'usage', label: t('layout.services.usage'), icon: }, - { value: 'mcp', label: 'MCP', icon: } - ); return base; }, [isDesktopApp, t]); - const servicesTabItems = React.useMemo(() => { - return servicesTabs.map((tab) => ({ - id: tab.value, - label: tab.label, - icon: tab.icon, - })); - }, [servicesTabs]); - - const showDevShutdown = React.useMemo(() => { - if (typeof window === 'undefined') return false; - if (isDesktopApp) return false; - if (isVSCode) return false; - const host = window.location.hostname; - return host === 'localhost' || host === '127.0.0.1' || host === '::1'; - }, [isDesktopApp, isVSCode]); - - const handleDevShutdown = React.useCallback(async () => { - if (isDevShutdownInFlight) return; - setIsDevShutdownInFlight(true); - setIsDesktopServicesOpen(false); - - const previewUrls: string[] = []; - let shutdownRequested = false; - try { - try { - for (const [, dirState] of useTerminalStore.getState().sessions.entries()) { - for (const tab of dirState.tabs) { - if (tab.previewUrl) { - previewUrls.push(tab.previewUrl); - } - } - } - } catch { - // ignore - } - - try { - // Ensure preview/dev terminals don't linger. - await runtimeApis.terminal.forceKill?.({}); - } catch { - // ignore - } - - try { - const devRes = await runtimeFetch('/api/system/dev-shutdown', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ previewUrls }), - }); - if (devRes.ok) { - shutdownRequested = true; - } else { - const shutdownRes = await runtimeFetch('/api/system/shutdown', { method: 'POST' }); - shutdownRequested = shutdownRes.ok; - } - } catch { - // ignore - } - } finally { - if (!shutdownRequested) { - setIsDevShutdownInFlight(false); - } - } - }, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]); - - const quotaDisplayTabs = React.useMemo(() => { - return [ - { value: 'usage' as const, label: t('header.services.used') }, - { value: 'remaining' as const, label: t('header.services.remaining') }, - ]; - }, [t]); - - const quotaDisplayTabItems = React.useMemo(() => { - return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label })); - }, [quotaDisplayTabs]); const mobileServicesTabItems = React.useMemo(() => { return [ @@ -2072,31 +1779,19 @@ export const Header: React.FC = ({ } else { setIsDesktopServicesOpen(true); void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } } return; } + // The desktop menu holds one destination now, so this shortcut opens it + // rather than cycling. The binding is kept: it is user-configurable and + // silently dropping it would break existing setups. const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); if (eventMatchesShortcut(e, cycleServicesCombo)) { e.preventDefault(); - - const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; - if (tabValues.length === 0) { - return; - } - - const currentIndex = tabValues.indexOf(desktopServicesTab); - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length; - const nextTab = tabValues[nextIndex]; - setDesktopServicesTab(nextTab); + if (servicesTabs.length === 0) return; setIsDesktopServicesOpen(true); void refreshCurrentInstanceLabel(); - if (nextTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } return; } @@ -2112,7 +1807,6 @@ export const Header: React.FC = ({ }, [ shortcutOverrides, isDesktopServicesOpen, - desktopServicesTab, servicesTabs, quotaResults.length, fetchAllQuotas, @@ -2172,6 +1866,10 @@ export const Header: React.FC = ({ const desktopSidebarActions = ( <> + {/* Instances only exist in the desktop app. On web the menu was left + holding a single dev-only shutdown action, which is not a reason to + keep a dropdown in the header. */} + {isDesktopApp ? ( = ({ isDesktopServicesOpen={isDesktopServicesOpen} setIsDesktopServicesOpen={setIsDesktopServicesOpen} refreshCurrentInstanceLabel={refreshCurrentInstanceLabel} - desktopServicesTab={desktopServicesTab} - setDesktopServicesTab={setDesktopServicesTab} - quotaResultsLength={quotaResults.length} - fetchAllQuotas={fetchAllQuotas} - servicesTabItems={servicesTabItems} - quotaLastUpdated={quotaLastUpdated} - quotaDisplayMode={quotaDisplayMode} - showPredValues={showPredValues} - quotaDisplayTabItems={quotaDisplayTabItems} - handleDisplayModeChange={handleDisplayModeChange} - handleUsageRefresh={handleUsageRefresh} - isQuotaLoading={isQuotaLoading} - isUsageRefreshSpinning={isUsageRefreshSpinning} - hasRateLimits={hasRateLimits} - rateLimitGroups={rateLimitGroups} - expandedFamilies={expandedFamilies} - toggleFamilyExpanded={toggleFamilyExpanded} shortcutLabel={shortcutLabel} - showDevShutdown={showDevShutdown} - isDevShutdownInFlight={isDevShutdownInFlight} - onDevShutdown={handleDevShutdown} remoteUpdateInfo={remoteUpdateInfo} remoteUpdateChecking={remoteUpdateChecking} remoteUpdateError={remoteUpdateError} onOpenRemoteUpdate={openRemoteInstanceUpdate} - timeFormatPreference={timeFormatPreference} /> + ) : null} = ({ {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} )} - {(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? ( + {showHeaderMetaRow ? ( {activeProjectLabel ? {activeProjectLabel} : null} {currentBranchLabel ? ( @@ -2342,7 +2020,12 @@ export const Header: React.FC = ({ ) : null}
-
+
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( = ({ percentIconClassName="h-4.5 w-4.5" /> ) : null} + = ({ className={cn(desktopHeaderIconButtonClass, 'mr-1')} Icon={'picture-in-picture-2'} /> + {activeMainTab === 'chat' && !isVSCode ? ( + + + + + + {workStatusPanelEnabled && !workStatusPanelFits + ? (workStatusOverlayOpen + ? t('header.workStatusPanel.hide') + : t('header.workStatusPanel.showOverlay')) + : workStatusPanelEnabled + ? t('header.workStatusPanel.hide') + : t('header.workStatusPanel.show')} + + + ) : null} + {desktopSidebarActions}
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 8755aae4..28e44ce8 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -437,7 +437,11 @@ export const MainLayout: React.FC = () => {
-
+ {/* Holds the chat and the context panel together, so its + width does not move when the context panel opens. The + work-status panel measures this rather than the chat, + which the context panel animates. */} +
diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index b867598b..92b7183b 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -128,7 +128,6 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl className={cn( 'relative flex h-full overflow-hidden border-r border-border will-change-[width] motion-reduce:transition-none', 'bg-sidebar oc-vibrancy-surface', - isOpen && 'shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]', !isOpen && 'border-r-0', className, )} @@ -144,6 +143,12 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl }} aria-hidden={!isOpen || appliedWidth === 0} > + {isOpen && ( +