From f4743ea06039584245da10683d2e634c05528d7c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 9 Aug 2026 19:30:25 +0300 Subject: [PATCH] feat(chat): work-status panel, and MCP auth and settings fixes (#2776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration. --- packages/electron/main.mjs | 33 ++ .../ui/src/apps/MobileSessionMetadata.tsx | 136 +---- .../ui/src/components/chat/ChatContainer.tsx | 71 ++- packages/ui/src/components/chat/ChatInput.tsx | 50 +- .../src/components/chat/SessionGoalButton.tsx | 17 +- .../chat/work-status/DOCUMENTATION.md | 344 ++++++++++++ .../work-status/WorkStatusContextSection.tsx | 130 +++++ .../chat/work-status/WorkStatusGoalRow.tsx | 76 +++ .../chat/work-status/WorkStatusMcpSection.tsx | 142 +++++ .../chat/work-status/WorkStatusPanel.tsx | 251 +++++++++ .../work-status/WorkStatusPinnedSection.tsx | 111 ++++ .../work-status/WorkStatusPrimaryGroup.tsx | 323 +++++++++++ .../chat/work-status/WorkStatusPrimitives.tsx | 266 +++++++++ .../work-status/WorkStatusSectionsDialog.tsx | 56 ++ .../WorkStatusSubagentsSection.tsx | 110 ++++ .../work-status/WorkStatusTasksSection.tsx | 112 ++++ .../work-status/WorkStatusUsageSection.tsx | 152 ++++++ .../chat/work-status/contextUsage.test.ts | 64 +++ .../chat/work-status/contextUsage.ts | 71 +++ .../components/chat/work-status/presence.tsx | 25 + .../chat/work-status/presenceContext.ts | 23 + .../chat/work-status/sections.test.ts | 48 ++ .../components/chat/work-status/sections.ts | 59 ++ .../chat/work-status/usageHeadline.test.ts | 95 ++++ .../chat/work-status/usageHeadline.ts | 66 +++ .../useWorkStatusVisibility.test.ts | 329 +++++++++++ .../work-status/useWorkStatusVisibility.ts | 116 ++++ .../desktop/DesktopHostSwitcher.tsx | 94 +--- packages/ui/src/components/icon/sprite.ts | 1 + .../components/layout/ContextPanelRail.tsx | 14 +- packages/ui/src/components/layout/Header.tsx | 514 ++++-------------- .../ui/src/components/layout/MainLayout.tsx | 6 +- packages/ui/src/components/layout/Sidebar.tsx | 7 +- .../ui/src/components/mcp/McpDropdown.tsx | 26 +- .../sections/mcp/McpOAuthCallbackPage.tsx | 49 +- .../src/components/sections/mcp/McpPage.tsx | 454 ++++++++-------- .../sections/mcp/startMcpAuthorization.ts | 212 ++++++++ .../sections/shared/SettingsPageLayout.tsx | 14 +- .../session/GitHubIssuePickerDialog.tsx | 20 +- .../components/session/NewWorktreeDialog.tsx | 31 ++ .../components/usage/UsageProviderCards.tsx | 87 +++ .../ui/src/components/usage/usageGroups.ts | 84 +++ packages/ui/src/lib/appearanceAutoSave.ts | 14 + packages/ui/src/lib/desktop.ts | 21 + packages/ui/src/lib/desktopCurrentHost.ts | 97 ++++ packages/ui/src/lib/i18n/messages/de.ts | 92 +++- packages/ui/src/lib/i18n/messages/en.ts | 92 +++- packages/ui/src/lib/i18n/messages/es.ts | 92 +++- packages/ui/src/lib/i18n/messages/fr.ts | 92 +++- packages/ui/src/lib/i18n/messages/ja.ts | 92 +++- packages/ui/src/lib/i18n/messages/ko.ts | 92 +++- packages/ui/src/lib/i18n/messages/pl.ts | 92 +++- packages/ui/src/lib/i18n/messages/pt-BR.ts | 92 +++- packages/ui/src/lib/i18n/messages/uk.ts | 92 +++- packages/ui/src/lib/i18n/messages/zh-CN.ts | 92 +++- packages/ui/src/lib/i18n/messages/zh-TW.ts | 92 +++- packages/ui/src/lib/linkedIssues.test.ts | 145 +++++ packages/ui/src/lib/linkedIssues.ts | 111 ++++ packages/ui/src/lib/persistence.ts | 21 + .../ui/src/stores/skillVisibility.test.ts | 75 +++ packages/ui/src/stores/skillVisibility.ts | 74 +++ packages/ui/src/stores/useMcpStore.ts | 9 + packages/ui/src/stores/useSkillsStore.ts | 17 +- packages/ui/src/stores/useUIStore.ts | 115 ++++ packages/ui/src/sync/session-actions.ts | 14 + packages/ui/src/sync/sync-context.tsx | 12 +- packages/web/server/lib/opencode/routes.js | 6 + .../server/lib/opencode/settings-helpers.js | 10 + .../web/server/lib/opencode/skill-routes.js | 29 +- 69 files changed, 5777 insertions(+), 894 deletions(-) create mode 100644 packages/ui/src/components/chat/work-status/DOCUMENTATION.md create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusGoalRow.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/contextUsage.test.ts create mode 100644 packages/ui/src/components/chat/work-status/contextUsage.ts create mode 100644 packages/ui/src/components/chat/work-status/presence.tsx create mode 100644 packages/ui/src/components/chat/work-status/presenceContext.ts create mode 100644 packages/ui/src/components/chat/work-status/sections.test.ts create mode 100644 packages/ui/src/components/chat/work-status/sections.ts create mode 100644 packages/ui/src/components/chat/work-status/usageHeadline.test.ts create mode 100644 packages/ui/src/components/chat/work-status/usageHeadline.ts create mode 100644 packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts create mode 100644 packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts create mode 100644 packages/ui/src/components/sections/mcp/startMcpAuthorization.ts create mode 100644 packages/ui/src/components/usage/UsageProviderCards.tsx create mode 100644 packages/ui/src/components/usage/usageGroups.ts create mode 100644 packages/ui/src/lib/desktopCurrentHost.ts create mode 100644 packages/ui/src/lib/linkedIssues.test.ts create mode 100644 packages/ui/src/lib/linkedIssues.ts create mode 100644 packages/ui/src/stores/skillVisibility.test.ts create mode 100644 packages/ui/src/stores/skillVisibility.ts 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 && ( +