feat(chat): work-status panel, and MCP auth and settings fixes (#2776)

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-09 19:30:25 +03:00
parent 493a618efc
commit f4743ea060
69 changed files with 5777 additions and 894 deletions
+33
View File
@@ -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());
+11 -125
View File
@@ -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<HTMLElement | null>;
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<{
</span>
</div>
<div className="space-y-1.5">
{groups.map((group) => (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">
{group.status}
</span>
) : null}
</div>
{group.rows.length > 0 ? (
<div className="mt-1.5 space-y-1">
{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 (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
) : null}
</span>
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
);
})}
</div>
) : null}
{group.status && group.rows.length > 0 ? (
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
) : null}
</div>
))}
</div>
<UsageProviderCards
groups={groups}
displayMode={displayMode}
timeFormatPreference={timeFormatPreference}
/>
</div>
);
};
@@ -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<MobileUsageProviderGroup[]>(() => {
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;
@@ -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<ChatContainerProps> = ({ 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<MessageListHandle | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -1152,7 +1197,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
@@ -1205,6 +1251,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
</div>
{/* 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 ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
/>
) : null}
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
@@ -1216,5 +1274,16 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
onLoadEarlier={handleLoadOlderClick}
/>
</div>
{/* 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 ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
/>
) : null}
</div>
);
};
+48 -2
View File
@@ -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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
<MemoStatusRow
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos
leftAccessory={newSessionDraftOpen || !hasPendingChanges ? null : <PendingChangesBar />}
showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null
: <PendingChangesBar />}
/>
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<DraftTargetSelectors
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -50,12 +51,13 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = 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<SessionGoalButtonProps> = React.memo(({
const button = (
<button
type="button"
className={cn(footerIconButtonClass, colorClass)}
className={footerIconButtonClass}
style={iconColor ? { color: iconColor } : undefined}
onClick={handleClick}
// Same guard as PermissionAutoAcceptButton, but only for the ARM
// toggle: arming happens mid-typing (the next message IS the
@@ -0,0 +1,344 @@
# Work-status panel
A card rendered to the right of the transcript inside `ChatContainer`. It
reports the state of the current session, its branch, its quotas and its
subagents.
## Structure
Every readout is a **labelled row**: icon, name, trailing value. A number
without a name is unreadable at a glance, which is what an unlabelled stream of
values degenerates into.
Rows are grouped into **named sections**, one component each, composed in
order by `WorkStatusPanel`. The separator between them is a
`:not(:first-child)` CSS rule rather than a prop, because every section renders
conditionally; passing "am I first?" down would mean each one tracking what the
sections above it decided to render.
Sections render nothing when they have no rows, so the panel collapses upward
instead of reserving empty space.
## What it is not
It is **not** a context-panel surface. It is not registered in
`lib/surfaces/registry.ts`, has no rail icon, no tab, no persisted width and no
resizer. It is a card floating inside the chat column — rounded border, faint
fill, its own margin — rather than a docked pane flush against the window edge.
## Placement
`ChatContainer`'s top-level return is a flex row:
- the existing chat column (`data-composer-bound`, `flex-1 min-w-0`), holding
the viewport, the composer and the timeline dialog;
- `WorkStatusPanel`, a fixed-width `shrink-0` sibling.
Nothing inside `ChatViewport` changed. The virtualizer sees the column shrink
exactly as it already does when the context panel opens.
## Visibility
`useWorkStatusVisibility` hides the panel when any of these hold:
- the user switched it off;
- the runtime is mobile or VS Code;
- the context panel is open for the active directory;
- the row cannot fit `WORK_STATUS_MIN_CHAT_WIDTH` of transcript alongside
`WORK_STATUS_PANEL_WIDTH` of panel.
`ChatContainer` additionally suppresses it in mini-chat and in expanded-input
mode, and the panel does not appear on a new-session draft: that branch returns
its own layout before the one that hosts the panel. The repository readouts
would apply there — branch and working-tree state inform what to ask for — so
this is a gap worth closing rather than a decision.
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal
when the node attaches, so the measuring effect read `.current`, found nothing
whenever the row mounted after the effect first ran, and only recovered on the
next unrelated dependency change — in practice, opening and closing the context
panel. `useWorkStatusVisibility.test.ts` covers a row that attaches late.
### Why the chat area is measured, not the chat column
**The width test must observe something the panel cannot resize.** The chat
column's width is an *output* of the visibility decision: hiding the panel
widens the chat, which would re-satisfy a chat-width test and re-show the
panel, which narrows the chat again — an infinite oscillation.
It measures the **chat area** — the container holding the chat and the context
panel together, marked `data-chat-area` in `MainLayout`. Measuring the chat row
instead reported a width still catching up while the context panel animated
closed, so the panel reappeared only once that number crossed the threshold:
the chat widened and then narrowed again. The chat area does not move when the
context panel opens. `useWorkStatusVisibility.test.ts` pins both properties.
The context-panel check mirrors `ContextPanel`'s own derivation: `isOpen` alone
is not enough, because a panel with no resolvable active tab renders nothing
and therefore displaces nothing.
## Data sources
Everything is read from already-warm caches. The panel adds no aggregated
endpoint and no polling of its own.
| Block | Source | Notes |
|---|---|---|
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` |
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
| PR + checks | `usePrVisualSummary` | **read-only** |
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
| Goal | `useSessionGoal` | respects the Settings toggle |
| MCP | `useMcpStore` | connect/disconnect reuses the dropdown's actions |
| Pinned messages | `getContextObligatoryMessages` + `state.part` | see below |
| Todos | live `state.todo[sessionId]`, persisted fallback | live channel wins |
### Context usage has its own computation, on purpose
`useSessionUIStore.getContextUsage` cannot serve this panel for two reasons:
1. It reads `getSyncMessages(sessionId)` with **no directory**, resolving to the
*current* directory's child store, and keys off the store's own
`currentSessionId`. A session held by another directory — a worktree, or the
moment after a directory switch — reads as "no messages", and the readout
vanished while the header still showed a value.
2. It is an **imperative getter**, as is `useConfigStore.getCurrentModel`.
Selecting one yields a reference that never changes, so calling it during
render subscribes to nothing; the readout went stale across session switches.
`contextUsage.ts` therefore computes the same quantity from messages the panel
has already subscribed to for a known session and directory, and the panel
subscribes to `currentProviderId` / `currentModelId` for the limits.
`contextUsage.test.ts` pins the arithmetic — notably that the *latest*
reporting assistant turn is the answer, not a sum across turns.
Two further rules on this readout:
- The displayed percentage is computed **unrounded**. `clampPercent` applies
`Math.round`, so routing the display value through it turned 33.6% into
"34.0%" and made the panel disagree with the header. Rounding is still right
for the colour threshold, which is what the header feeds it.
- When the model exposes no context limit, the percentage falls back to the
store's own default limit instead of disappearing.
There is no cost-only fallback row. A row labelled "Context" showing nothing but
a price is not a context reading; cost rides along with the percentage or waits
for it.
### Pinned messages load only what they need
Pins 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. The section materialises the session, but only when a pin actually
resolves to nothing: having pins is not a reason to fetch a session, and
neither is something being unloaded in general.
### PR status is deliberately read-only
The panel never calls `startWatching`. PR watching is owned by the background
tracker, and its concurrency gate exists because per-consumer PR fetches once
saturated the browser's connection pool and stalled startup for ~20s. A panel
that started a watch per open session would reintroduce exactly that fan-out.
### Changed files come from git status, not the session
`Session.summary` looks like the obvious source and does not work. OpenCode's
`SessionSummary.summarize` writes `{additions: 0, deletions: 0, files: 0}` at
the start of every turn and then fills only the **message**-level
`summary.diffs`; session-level totals stay zero forever. The `session.diff`
event is reset to `[]` in the same place and carries real content only on
revert, so `state.session_diff` is not an aggregate either.
That leaves two honest options: aggregate per-message `summary.diffs` across
every turn, or read git status. The panel reads git status — it is
authoritative, already cached per directory, costs nothing extra, and sits
directly under the branch row where working-tree state is what a reader
expects.
The consequence is a real semantic difference: this counts the working tree,
including edits the user made by hand and excluding session edits that are
already committed. If a session-authored count is ever needed, it has to come
from aggregating message summaries, not from `Session.summary`.
## Section order
Ordering is by durability, not category:
1. **Session** (goal, context, cost), **Repository** (attention, branch,
changes, PR, checks) and **Usage** — true for as long as the session is
open. Usage sits here rather than lower down because a spent quota stops the
work outright;
2. **Subagents**, **Tasks** — what is happening right now;
3. **MCP**, **Pinned messages**, **Context sources** — supporting material.
## Switching it off
A persisted preference (`workStatusPanelEnabled`) drives a header toggle, and a
dialog behind the equalizer icon switches individual sections off. Hidden
sections are stored rather than visible ones, so a section added later appears
for everyone instead of staying invisible to whoever had saved settings before
it existed. Both travel the full settings pipeline, including the server
whitelist without which the keys never reach `settings.json`.
`workStatusPanelVisible` is separate and transient: the switch can be on while
layout still refuses the panel. The header and the git rail read it to drop the
readouts the panel already carries, and it is deliberately not persisted — it
describes the current frame, not a preference.
## Appearing and disappearing
The panel collapses on the context panel's own curve and duration rather than
unmounting, and slides out to the right with a fade when switched off. It stays
mounted wherever it could ever show, so the collapse has something to animate;
its content is dropped once the collapse finishes.
An empty card is a border around a settings icon, which reads as a fault. Each
section decides for itself that it has nothing to say, so they report through
`presenceContext.ts` and the panel collapses when none rendered. Deriving that
at the panel level would mean duplicating every data source the sections read.
The scroll offset resets on session change: restoring one session's offset into
another's shorter panel lands somewhere arbitrary.
The Subagents section opens itself when subagents appear where there were none,
on that edge only: re-expanding on every count change would fight a user who
just collapsed it.
## Tasks
Icons and strike-through match the composer's todo dropdown, so one list does
not read as two. Two deliberate differences:
- **Completed items stay.** The dropdown is a queue to work through; this is a
record of the session.
- **Sorted by status** — in progress, then pending, then completed — and stable
within each rank, since the agent's own ordering carries meaning.
Rows truncate at this width, so each carries a delayed tooltip with the full
task text.
## Collapsed Usage headline
Collapsed, the Usage section shows one quota rather than a mode word: the
**shortest window reported by the provider the composer is pointed at**. A
5-hour bucket answers "will the next turn land"; a monthly one does not.
Selection rules live in `usageHeadline.ts` and are pinned by
`usageHeadline.test.ts`:
- provider ids are matched directly, with a small alias table for the ones that
diverge from OpenCode's (`openai`/`chatgpt``codex`, `anthropic``claude`,
`gemini``google`);
- model-scoped rows are skipped while any provider-level row exists — a
per-model quota is not the provider's;
- rows without a window duration (credit balances, tool counters) are a last
resort, never preferred over a real window;
- **no match means no headline.** The section falls back to the display-mode
label, because showing an unmatched provider's quota would read as the active
one.
## Actions
Rows that name something the app can already show are buttons:
| Row | Opens |
|---|---|
| Context | the context overview (`openContextOverview`), same destination as the header readout |
| Changes | working-tree diff (`openContextPanelTab`, `diffScope: 'working'`, no target path) |
| Branch | git surface (`openContextSurface(dir, 'git')`) |
| Pull request, Checks | PR surface (`openContextSurface(dir, 'pr')`) |
| Subagent | that child session's chat tab, read-only |
| Goal (row) | the composer's own `SessionGoalDialog` |
| Goal (pause/resume) | `setSessionGoalStatus(sessionId, directory, status)` |
| MCP switch | connects/disconnects the server |
| MCP status | the state doubles as the button that reconnects |
| Pinned (pin icon) | unpins the message |
| Pinned (text) | jumps the transcript to that message |
The goal icon reproduces the **composer target button's** colour mapping, not
the goal strip's. The two disagree today — the strip paints `paused` muted and
`blocked` warning, the button paints them info and error — and the button is
where this panel's reader last saw the goal. Unifying them is a separate change.
Jumping to a message goes through the `#message-<id>` URL hash, which
`useChatTurnNavigation` listens for inside `ChatContainer`. It is the only
cross-component jump the chat exposes; there is no store action or ref
registry. An unchanged hash fires no event, so the panel clears it first to make
a repeat press work.
Opening a subagent takes the same branch as the transcript's Task tool: an
embedded panel, mobile, or VS Code navigates to the session instead of nesting
a tab.
## Context sources
Linked GitHub threads first, then skills and MCP counts.
Agents are deliberately absent: an agent is who does the work, not material
loaded into the context. Tools are absent too — `Agent.tools` is a per-agent
override map rather than a registry, so its size would be a number that means
something other than "tools available".
### Linked issues and pull requests
Written by the flows that already attach a thread — the composer's issue/PR
pickers, and session creation from an issue or PR in `NewWorktreeDialog` and
`GitHubIssuePickerDialog`. There is no manual "link this" control: attaching a
thread to the work *is* the act of linking it.
Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
`openchamber.linked_issues`), riding the same `patchSessionMetadata` channel as
pinned messages. Number, title, url, author and avatar only — the body,
comments and state belong to GitHub, and mirroring them would mean owning their
staleness. The stored title can drift; that is the price of a store that never
needs refreshing. The row opens the real thread, which is where current state
lives.
Writes happen **after** the send promise resolves and are deliberately
swallowed on failure: the message went out, and a missing bookkeeping entry
must not surface as a send error.
The entry id comes from the thread URL rather than a separate owner/repo pair,
because every attach flow has the URL and only some carry the repo separately.
Issues and pull requests share one id shape, since they share a numbering space
per repository.
## Loading data the header used to own
Two readouts had no loader of their own and appeared only after the user opened
the matching header dropdown:
- **MCP**`McpDropdown` was the only mount-time caller of `refresh()`.
- **Usage**`useQuotaAutoRefresh` merely schedules an interval; the *first*
fetch was performed by the dropdown's open handler.
- **Skills**`loadSkills()` ran only when the composer's slash autocomplete
opened, so the context-sources count was whatever happened to be cached. The
section loads them itself, keyed on the directory, since skills are
discovered relative to the active project. It does not wrap the call in
`runBackgroundNetworkTask`: the store already gates its own fetch.
The panel now performs these itself, silently and through the
background-network gate, so it cannot compete with chat bootstrap traffic for
sockets. A panel that reports a subsystem's state cannot depend on an unrelated
component having been mounted or opened.
## Persisted panel state
Expanded sections (`workStatusExpandedSections`, keyed by a stable section id)
and the scroll offset (`workStatusScrollTop`) live in the persisted
`useUIStore`. Component state would not do: the panel unmounts every time the
context panel opens, which would silently discard the user's arrangement.
The scroll offset is restored in the scroller's callback ref, at the moment it
attaches, and read through `useUIStore.getState()` rather than a subscription —
subscribing would fight the user mid-scroll. Writes are coalesced to one per
animation frame.
## Not implemented yet
- Test/build/dev-server status and LSP diagnostics — a separate track. Note
that `state.lsp` already exists in the sync state.
@@ -0,0 +1,130 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Icon } from '@/components/icon/Icon';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
type Props = {
sessionId: string | null;
directory: string | null;
};
/**
* What is loaded into the agent's context: the GitHub threads this session was
* pointed at, plus how much ambient material is available.
*
* Agents are deliberately absent an agent is who does the work, not material
* the work is done with. Tools are absent for want of an honest source:
* `Agent.tools` is a per-agent override map, not a registry, so its size would
* report something other than "tools available".
*/
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const skills = useSkillsStore((state) => state.skills);
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
// Skills were previously fetched only when the composer's slash autocomplete
// opened, so this row reported whatever count happened to be cached — often
// none — until the user typed "/". The panel states a count, so it is the
// panel's business to have one. Re-run per directory because skills are
// discovered relative to the active project. No background-network wrap
// here: `loadSkills` already gates its own fetch, and wrapping it again
// would hold a second slot idle for the length of the first.
const loadSkills = useSkillsStore((state) => state.loadSkills);
React.useEffect(() => {
void loadSkills();
}, [directory, loadSkills]);
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
// Connected servers only. A disabled server contributes nothing to the
// context, so counting it here contradicts the MCP section right above,
// which shows the same servers switched off.
const mcpCount = React.useMemo(
() => Object.values(mcpStatus ?? {}).filter((entry) => entry?.status === 'connected').length,
[mcpStatus],
);
useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0);
if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null;
// The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts
// when there is not. `1 · 33 · 2` said nothing without opening the section.
const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
const prCount = linked.length - issueCount;
const summaryParts: string[] = [];
if (issueCount > 0) {
summaryParts.push(issueCount === 1
? t('chat.workStatus.breakdown.issueCountSingle', { count: issueCount })
: t('chat.workStatus.breakdown.issueCountPlural', { count: issueCount }));
}
if (prCount > 0) {
summaryParts.push(prCount === 1
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
}
if (summaryParts.length === 0) {
if (skills.length > 0) {
summaryParts.push(skills.length === 1
? t('chat.workStatus.breakdown.skillCountSingle', { count: skills.length })
: t('chat.workStatus.breakdown.skillCountPlural', { count: skills.length }));
}
if (mcpCount > 0) {
summaryParts.push(mcpCount === 1
? t('chat.workStatus.breakdown.mcpCountSingle', { count: mcpCount })
: t('chat.workStatus.breakdown.mcpCountPlural', { count: mcpCount }));
}
}
return (
<WorkStatusCollapsibleSection
id="context-sources"
title={t('chat.workStatus.section.contextBreakdown')}
icon="stack"
summary={summaryParts.join(' · ')}
>
{/* Attached threads first: they are specific to this session, while the
counts below describe the workspace. */}
{linked.map((entry) => (
<WorkStatusRow
key={entry.id}
leading={entry.authorAvatarUrl ? (
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
) : (
<Icon
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
className="size-4 shrink-0 text-muted-foreground"
/>
)}
label={entry.title}
muted
// The stored snapshot is enough to render; the live thread only ever
// exists on github.com.
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
/>
))}
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.skills')}
value={<WorkStatusValue>{skills.length}</WorkStatusValue>}
/>
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.mcp')}
value={<WorkStatusValue>{mcpCount}</WorkStatusValue>}
/>
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,76 @@
import React from 'react';
import { toast } from 'sonner';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives';
type Props = {
sessionId: string | null;
directory: string | null;
};
/** The session goal, on the mapping every other goal surface uses. */
export const WorkStatusGoalRow: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory ?? undefined);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const handleToggleStatus = React.useCallback(async (nextStatus: 'active' | 'paused') => {
if (!sessionId || busy) return;
setBusy(true);
try {
await setSessionGoalStatus(sessionId, directory ?? undefined, nextStatus);
} catch {
toast.error(t('chat.workStatus.goal.updateFailed'));
} finally {
setBusy(false);
}
}, [busy, directory, sessionId, t]);
const objective = enabled && goal ? goal.objective?.trim() || null : null;
if (!objective || !sessionId) return null;
// No control while complete: there is nothing left to pause or resume.
const canPause = goal?.status === 'active';
const canResume = goal?.status === 'paused'
|| goal?.status === 'blocked'
|| goal?.status === 'budgetLimited';
return (
<>
<WorkStatusRow
leading={(
<Icon
name={goal?.status ? 'target-fill' : 'target'}
className="size-4 shrink-0"
style={{ color: goal ? sessionGoalStatusColor[goal.status] : undefined }}
/>
)}
label={objective}
onClick={() => setDialogOpen(true)}
ariaLabel={t('chat.workStatus.goal.open')}
value={canPause || canResume ? (
<WorkStatusRowAction
tone={canPause ? 'info' : 'warning'}
disabled={busy}
ariaLabel={canPause ? t('chat.workStatus.goal.pause') : t('chat.workStatus.goal.resume')}
onClick={() => { void handleToggleStatus(canPause ? 'paused' : 'active'); }}
>
{canPause ? t('chat.workStatus.goal.pause') : t('chat.workStatus.goal.resume')}
</WorkStatusRowAction>
) : undefined}
/>
<SessionGoalDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
sessionId={sessionId}
directory={directory ?? undefined}
/>
</>
);
};
@@ -0,0 +1,142 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Switch } from '@/components/ui/switch';
import { useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { toast } from 'sonner';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
type Props = {
directory: string | null;
};
/**
* MCP servers with their connection switches, reusing the dropdown's own
* connect/disconnect actions.
*/
export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const { t } = useI18n();
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
const refreshMcp = useMcpStore((state) => state.refresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
// opened to know its MCP servers. Silent and background-gated, so it cannot
// compete with chat bootstrap traffic for sockets.
React.useEffect(() => {
void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true }));
}, [directory, refreshMcp]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
[mcpStatus],
);
const mcpConnected = React.useMemo(
() => mcpServers.filter(([, entry]) => entry?.status === 'connected').length,
[mcpServers],
);
// A server waiting on authorization cannot be reconnected into working
// order: `connect` just repeats the attempt that produced `needs_auth`.
// Authorising sends the user to the provider instead.
const handleAuthorize = React.useCallback(async (name: string) => {
setBusyServer(name);
try {
const { opened } = await startMcpAuthorization({
name,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('chat.workStatus.mcp.authorizeOpenFailed'));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.workStatus.mcp.authorizeFailed'));
} finally {
setBusyServer((current) => (current === name ? null : current));
}
}, [directory, t]);
const handleToggle = React.useCallback(async (name: string, next: boolean) => {
// Switching on a server that is waiting for sign-in cannot connect: it only
// repeats the attempt that produced `needs_auth`. Authorization is the real
// action, and the dropdown already routes the same switch that way — the
// two surfaces must not disagree about what this control does.
const status = (mcpStatus ?? {})[name]?.status;
if (next && (status === 'needs_auth' || status === 'needs_client_registration')) {
await handleAuthorize(name);
return;
}
setBusyServer(name);
try {
if (next) await connect(name, directory);
else await disconnect(name, directory);
} finally {
setBusyServer((current) => (current === name ? null : current));
}
}, [connect, disconnect, directory, handleAuthorize, mcpStatus]);
useReportWorkStatusPresence('mcp', mcpServers.length > 0);
if (mcpServers.length === 0) return null;
return (
<WorkStatusCollapsibleSection
id="mcp"
title={t('chat.workStatus.section.mcp')}
iconNode={<McpIcon className="size-4 shrink-0 text-muted-foreground" />}
summary={`${mcpConnected}/${mcpServers.length}`}
>
{mcpServers.map(([name, entry]) => {
const connected = entry?.status === 'connected';
const needsAuth = entry?.status === 'needs_auth' || entry?.status === 'needs_client_registration';
const failed = entry?.status === 'failed';
return (
<WorkStatusRow
key={name}
leading={(
<Switch
checked={connected}
disabled={busyServer === name}
className="scale-75 data-[checked]:bg-status-info"
aria-label={t('chat.workStatus.mcp.toggle', { name })}
onCheckedChange={(checked) => { void handleToggle(name, checked); }}
/>
)}
label={name}
muted={!connected}
// A server asking for sign-in or reporting a failure is asking to be
// acted on; the state is the affordance, so it is the button.
value={needsAuth ? (
<WorkStatusRowAction
tone="warning"
disabled={busyServer === name}
onClick={() => { void handleAuthorize(name); }}
>
{t('chat.workStatus.mcp.needsAuth')}
</WorkStatusRowAction>
) : failed ? (
<WorkStatusRowAction
tone="error"
disabled={busyServer === name}
onClick={() => { void handleToggle(name, true); }}
>
{t('chat.workStatus.mcp.failed')}
</WorkStatusRowAction>
) : undefined}
/>
);
})}
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,251 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useUIStore } from '@/stores/useUIStore';
import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup';
import { WorkStatusUsageSection } from './WorkStatusUsageSection';
import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection';
import { WorkStatusTasksSection } from './WorkStatusTasksSection';
import { WorkStatusMcpSection } from './WorkStatusMcpSection';
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
import { WorkStatusContextSection } from './WorkStatusContextSection';
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
import { isWorkStatusSectionVisible } from './sections';
import { WorkStatusPresenceProvider } from './presence';
import { Icon } from '@/components/icon/Icon';
type Props = {
/** Null on a new-session draft: repository readouts still apply. */
sessionId: string | null;
directory: string | null;
/** Whether the panel should currently occupy space. */
visible: boolean;
/**
* Floats over the transcript instead of sitting beside it, for when the chat
* is too narrow to give it a column of its own.
*/
overlay?: boolean;
};
/**
* Matches the context panel's own width animation exactly.
*
* The two are siblings of the transcript, and opening the context panel hides
* this one. With an instant unmount the chat first jumped wider (this panel
* gone) and then eased narrower (the context panel expanding) two opposite
* width changes in a row, which reads as a flutter. Collapsing on the same
* curve and duration makes the chat's width move once, in one direction.
*/
const PANEL_TRANSITION_MS = 200;
const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
/**
* Work-status panel: a card inside the chat column reporting the state of the
* session, its branch and its subagents.
*
* Ordering is by durability, not by category. The first sections hold readouts
* that stay true for the whole session, then the state of the work in flight,
* then episodic material an agent may never produce. Each section renders
* nothing when it has nothing, so the panel collapses toward the top instead of
* reserving empty space.
*
* The card clips; the scroller lives inside it, so the same top/bottom scroll
* shadows the transcript uses stay within the rounded border instead of
* bleeding past it. The scrollbar itself is hidden at this width it would
* eat a visible slice of every row's trailing value, and the shadows already
* say there is more to see.
*/
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, overlay = false }) => {
const { t } = useI18n();
const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop);
const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
const hiddenSections = useUIStore((state) => state.workStatusHiddenSections);
const [sectionsDialogOpen, setSectionsDialogOpen] = React.useState(false);
// Starts optimistic: sections report after their first commit, and rendering
// nothing on the way in would make the card flash out and back on arrival.
const [renderedSections, setRenderedSections] = React.useState(1);
const sectionVisible = React.useCallback(
(sectionId: Parameters<typeof isWorkStatusSectionVisible>[1]) =>
isWorkStatusSectionVisible(hiddenSections, sectionId),
[hiddenSections],
);
const frameRef = React.useRef<number | null>(null);
// Restoring the offset has to happen the moment the scroller attaches, and
// the panel unmounts whenever the context panel opens. Reading the stored
// value through a ref keeps this a mount-time restore rather than a
// subscription that would fight the user mid-scroll.
// Content is dropped only after the collapse finishes, so the card animates
// out with something in it rather than emptying first, and its subscriptions
// stop once it is truly gone.
const [contentMounted, setContentMounted] = React.useState(visible);
// Hidden, mid-collapse, or reporting nothing: in each case the card is not
// something the user can act on, so it should not be reachable.
const interactive = visible && renderedSections > 0;
React.useEffect(() => {
if (visible) {
setContentMounted(true);
return undefined;
}
const timer = window.setTimeout(() => setContentMounted(false), PANEL_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [visible]);
const restore = React.useCallback((node: HTMLElement | null) => {
if (!node) return;
const stored = useUIStore.getState().workStatusScrollTop;
if (stored > 0) node.scrollTop = stored;
}, []);
// Coalesced to one write per frame: scroll fires far faster than the store
// needs to hear about it.
const handleScroll = React.useCallback((event: React.UIEvent<HTMLElement>) => {
const { scrollTop } = event.currentTarget;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
setScrollTop(scrollTop);
});
}, [setScrollTop]);
React.useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
}, []);
// The offset belongs to the panel a session produced, not to the panel in
// general: restoring one session's scroll into another's shorter panel lands
// somewhere arbitrary.
React.useEffect(() => {
setScrollTop(0);
}, [sessionId, setScrollTop]);
// Dismissed like any transient surface: a click elsewhere or Escape. It
// covers the transcript, so leaving it up would block the thing it reports on.
const overlayRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
// Only while it is actually up: a hidden overlay listening for clicks would
// swallow the very press that opens it.
if (!overlay || !visible) return undefined;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (overlayRef.current?.contains(target)) return;
// The header toggle closes it on its own; letting this fire too would
// close and immediately reopen.
if (target?.closest('[data-work-status-toggle]')) return;
setOverlayOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOverlayOpen(false);
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
};
}, [overlay, setOverlayOpen, visible]);
return (
<aside
ref={overlayRef}
aria-label={t('chat.workStatus.ariaLabel')}
aria-hidden={!interactive}
// The card stays mounted while hidden so it can animate its own collapse,
// and the sections button sits outside the content gate. Without `inert`
// Tab could land on an invisible control — and `aria-hidden` around a
// focusable descendant is an accessibility fault in its own right.
inert={!interactive}
className={cn(
// `self-start` keeps the card at content height instead of stretching
// to the row; `max-h` then caps it so a long panel scrolls rather than
// overflowing the chat.
// A left margin as well as a right one: flush against the transcript
// the card's own shadow had no room and was clipped down that edge.
'relative my-4 flex shrink-0 flex-col self-start overflow-hidden',
'max-h-[calc(100%-2rem)]',
interactive ? 'ml-2 mr-4' : 'ml-0 mr-0',
// Out of the flow entirely, anchored to the chat column's top-right so
// it reads as a dropdown from the header button. As a flex child it
// took part in the layout and pushed the transcript, which is the one
// thing an overlay must not do. Stronger shadow: it sits on content now.
overlay && [
'absolute right-3 top-3 z-30 mx-0 my-0',
'max-h-[calc(100%-1.5rem)]',
'shadow-[0_8px_28px_-8px_rgb(0_0_0_/_0.28)]',
// Beside the transcript the translucent fill reads as depth; on top
// of it, message bubbles showed straight through the rows. Frosting
// separates the two without going fully opaque.
'bg-[var(--surface-muted)]/80 backdrop-blur-md',
],
// An empty card is a border around a settings icon, which reads as a
// fault rather than as "nothing to report".
renderedSections === 0 && 'border-transparent bg-transparent shadow-none',
'motion-reduce:transition-none',
'rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-muted)]/40',
// A lighter version of the composer's lift: the same shape, but this
// card is taller, so the composer's spread reads as heavy here.
'shadow-[0_2px_8px_-3px_rgb(0_0_0_/_0.08)]',
)}
style={{
// The overlay keeps its width: it takes no space from the chat, so
// collapsing it would animate a dimension nothing depends on. It fades
// and lifts instead, like the dropdown it reads as.
width: overlay || interactive ? WORK_STATUS_PANEL_WIDTH : 0,
opacity: interactive ? 1 : 0,
transform: visible
? 'translateY(0) scale(1)'
: overlay
? 'translateY(-6px) scale(0.98)'
// Inline: leaves to the right and arrives from it, so the card
// reads as sliding out past the window edge.
: `translateX(${WORK_STATUS_PANEL_WIDTH / 4}px)`,
transformOrigin: 'top right',
transitionProperty: 'width, opacity, transform, margin',
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
transitionTimingFunction: PANEL_TRANSITION_EASING,
pointerEvents: interactive ? undefined : 'none',
}}
>
{/* Overlaid rather than placed in flow: the panel has no header of its
own, and giving it one would cost a row of height on every session. */}
<button
type="button"
aria-label={t('chat.workStatus.sections.open')}
onClick={() => setSectionsDialogOpen(true)}
className="absolute right-2 top-1.5 z-10 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<Icon name="equalizer-2" className="size-4" />
</button>
{contentMounted ? (
<WorkStatusPresenceProvider onChange={setRenderedSections}>
<ScrollShadow
ref={restore}
onScroll={handleScroll}
size={24}
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
>
<WorkStatusPrimaryGroup
sessionId={sessionId}
directory={directory}
showSession={sectionVisible('session')}
showRepository={sectionVisible('repository')}
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
/>
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
{sectionVisible('subagents') ? <WorkStatusSubagentsSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('tasks') ? <WorkStatusTasksSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('mcp') ? <WorkStatusMcpSection directory={directory} /> : null}
{sectionVisible('pinned') ? <WorkStatusPinnedSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('contextSources') ? <WorkStatusContextSection sessionId={sessionId} directory={directory} /> : null}
</ScrollShadow>
</WorkStatusPresenceProvider>
) : null}
<WorkStatusSectionsDialog open={sectionsDialogOpen} onOpenChange={setSectionsDialogOpen} />
</aside>
);
};
@@ -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<Props> = ({ 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<string | null>(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<typeof part, { type: 'text' }> => 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-<id>` 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 (
<WorkStatusSection title={t('chat.workStatus.section.pinned')}>
{pinned.map((entry) => (
<WorkStatusRow
key={entry.id}
leading={(
<button
type="button"
disabled={busyId === entry.id}
aria-label={t('chat.workStatus.pinned.unpin')}
onClick={(event) => {
event.stopPropagation();
void handleUnpin(entry.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
muted
label={entry.text ?? t('chat.workStatus.pinned.unavailable')}
onClick={() => handleReveal(entry.id)}
ariaLabel={t('chat.workStatus.pinned.reveal')}
/>
))}
</WorkStatusSection>
);
};
@@ -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<Props> = ({ 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<string, unknown>)
: 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 ? (
<WorkStatusSection title={t('chat.workStatus.section.session')}>
{usagePercent !== null ? (
<>
<WorkStatusRow
icon="donut-chart"
onClick={directory ? openContext : undefined}
ariaLabel={t('chat.workStatus.action.openContext')}
label={t('chat.workStatus.context.label')}
value={(
<>
<WorkStatusValue>{formatPercent(usagePercent)}</WorkStatusValue>
{/* No icon of its own: the sprite has no currency glyph, and
spend belongs with consumption anyway. The `$` labels it. */}
{cost !== null ? <WorkStatusValue tone="muted">{formatCost(cost)}</WorkStatusValue> : null}
</>
)}
/>
<WorkStatusMeter percent={usagePercent} color={meterColor} />
</>
) : null}
{/* Below the context readout: the goal is a standing instruction,
while context is the live number the reader came for. */}
{goalRow}
</WorkStatusSection>
) : null}
{hasRepository ? (
<WorkStatusSection
title={t('chat.workStatus.section.repository')}
summary={projectLabel}
>
{attentionLabel ? <WorkStatusCallout>{attentionLabel}</WorkStatusCallout> : 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 ? (
<WorkStatusRow
icon="git-branch"
onClick={directory ? () => openSurface('git') : undefined}
ariaLabel={t('chat.workStatus.action.openGit')}
label={branch}
value={(gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0 ? (
<>
{(gitStatus?.ahead ?? 0) > 0
? <WorkStatusValue tone="muted">{`${gitStatus?.ahead}`}</WorkStatusValue> : null}
{(gitStatus?.behind ?? 0) > 0
? <WorkStatusValue tone="muted">{`${gitStatus?.behind}`}</WorkStatusValue> : null}
</>
) : undefined}
/>
) : null}
{changed ? (
<WorkStatusRow
icon="file-edit"
onClick={directory ? openChanges : undefined}
ariaLabel={t('chat.workStatus.action.openChanges')}
// The count names the row, matching the composer's changed-files
// bar; the diffstat stays the trailing value.
label={changed.files === 1
? t('chat.workStatus.git.changedFileSingle', { count: changed.files })
: t('chat.workStatus.git.changedFilePlural', { count: changed.files })}
value={changed.hasStats && (changed.additions > 0 || changed.deletions > 0) ? (
<>
<WorkStatusValue tone="success">{`+${changed.additions}`}</WorkStatusValue>
{/* Neutral separator: colouring it would imply it carries a
status of its own. */}
<WorkStatusValue tone="muted">/</WorkStatusValue>
<WorkStatusValue tone="error">{`${changed.deletions}`}</WorkStatusValue>
</>
) : undefined}
/>
) : null}
{prSummary ? (
<>
<WorkStatusRow
icon="git-pull-request"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openPr')}
iconColor={`var(--pr-${prSummary.visualState})`}
label={prSummary.title ?? t('chat.workStatus.pr.untitled')}
value={(
<WorkStatusPill
color={`var(--pr-${prSummary.visualState})`}
background={`color-mix(in srgb, var(--pr-${prSummary.visualState}) 18%, transparent)`}
>
{prSummary.draft ? t('chat.workStatus.pr.draft') : `#${prSummary.number}`}
</WorkStatusPill>
)}
/>
{prSummary.checks && prSummary.checks.total > 0 ? (
<WorkStatusRow
icon="checkbox-circle"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openPr')}
label={t('chat.workStatus.pr.checks')}
muted
value={(
<>
{prSummary.checks.failure > 0 ? (
<WorkStatusValue tone="error">
{t('chat.workStatus.pr.checksFailed', { count: prSummary.checks.failure })}
</WorkStatusValue>
) : null}
{prSummary.checks.pending > 0 ? (
<WorkStatusValue tone="warning">
{t('chat.workStatus.pr.checksPending', { count: prSummary.checks.pending })}
</WorkStatusValue>
) : null}
{prSummary.checks.failure === 0 && prSummary.checks.pending === 0 ? (
<WorkStatusValue tone="success">
{t('chat.workStatus.pr.checksPassed', { count: prSummary.checks.success })}
</WorkStatusValue>
) : null}
</>
)}
/>
) : null}
</>
) : null}
</WorkStatusSection>
) : null}
</>
);
};
@@ -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 }) => (
<section className={SECTION_CLASS}>
<div className="mb-0.5 flex items-center gap-2 px-1">
<h3 className={cn(HEADING_CLASS, 'min-w-0 flex-1 truncate')}>{title}</h3>
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</div>
{children}
</section>
);
/**
* 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 (
<section className={SECTION_CLASS}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedInStore(id, !expanded)}
className={cn(
'group/section mb-0.5 flex h-6 items-center gap-1.5 rounded-md px-1 text-left',
// No hover fill anywhere in the panel: at this row density the blocks
// of colour read as selection, not as affordance. Interactivity shows
// through the text instead.
'transition-colors hover:text-foreground',
)}
>
{iconNode ?? (icon ? (
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
/>
) : null)}
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
<Icon
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="flex-1" />
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</button>
{expanded ? children : null}
</section>
);
};
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<RowProps> = ({
icon,
iconColor,
leading,
label,
value,
muted,
onClick,
ariaLabel,
className,
}) => {
const body = (
<>
{leading ?? (icon ? (
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
/>
) : null)}
<span className={cn('min-w-0 flex-1 truncate text-[13px]', muted && 'text-muted-foreground')}>
{label}
</span>
{value !== undefined && value !== null ? (
<span className="flex shrink-0 items-center gap-1.5 text-[13px] tabular-nums">{value}</span>
) : null}
</>
);
const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className);
if (!onClick) return <div className={shared}>{body}</div>;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(shared, 'transition-colors hover:text-foreground')}
>
{body}
</button>
);
};
type WorkStatusTone = 'default' | 'muted' | 'success' | 'error' | 'warning' | 'info';
const TONE_COLOR: Record<Exclude<WorkStatusTone, 'default' | 'muted'>, 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' }) => (
<span
className={tone === 'muted' ? 'text-muted-foreground' : undefined}
style={tone === 'default' || tone === 'muted' ? undefined : { color: TONE_COLOR[tone] }}
>
{children}
</span>
);
/**
* 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 (
<button
type="button"
aria-label={ariaLabel}
disabled={disabled}
onClick={(event) => {
// The row underneath is often a button of its own with a different
// destination.
event.stopPropagation();
onClick();
}}
className={cn(
'shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium leading-4 transition-opacity',
'hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
)}
style={color
? { color, backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }
: undefined}
>
{children}
</button>
);
};
export const WorkStatusPill: React.FC<{
children: React.ReactNode;
color?: string;
background?: string;
}> = ({ children, color, background }) => (
<span
className={cn(
'rounded-full px-1.5 py-px text-[11px] font-medium leading-4',
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
)}
style={color ? { color, backgroundColor: background } : undefined}
>
{children}
</span>
);
/** Full-width callout for states that block the branch (merge, rebase, …). */
export const WorkStatusCallout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
className="mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] font-medium"
style={{ backgroundColor: 'var(--status-warning-background)', color: 'var(--status-warning)' }}
>
<Icon name="alert" className="size-4 shrink-0" />
<span className="min-w-0 truncate">{children}</span>
</div>
);
/** Context-window fill, drawn under its row rather than inside it. */
export const WorkStatusMeter: React.FC<{ percent: number; color: string }> = ({ percent, color }) => (
<div className="mx-1 mb-1 h-1 overflow-hidden rounded-full bg-[var(--chat-divider)]">
<div
className="h-full rounded-full"
style={{ width: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: color }}
/>
</div>
);
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('chat.workStatus.sections.dialogTitle')}</DialogTitle>
<DialogDescription>{t('chat.workStatus.sections.dialogDescription')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col">
{WORK_STATUS_SECTION_IDS.map((sectionId) => (
<SettingsCheckboxRow
key={sectionId}
settingsItem={`chat.work-status.section.${sectionId}`}
checked={isWorkStatusSectionVisible(hidden, sectionId)}
onChange={(checked) => setSectionVisible(sectionId, checked)}
label={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])}
ariaLabel={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])}
/>
))}
</div>
</DialogContent>
</Dialog>
);
};
@@ -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<Props> = ({ 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 (
<WorkStatusCollapsibleSection
id={SECTION_ID}
title={t('chat.workStatus.section.subagents')}
icon="ai-agent"
defaultExpanded
summary={busyChildren > 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 (
<WorkStatusRow
key={child.id}
onClick={directory ? () => openChildSession(child.id, label) : undefined}
ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })}
label={label}
value={blocked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.needsPermission')}</WorkStatusValue>
) : asked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.askedQuestion')}</WorkStatusValue>
) : busy ? (
<WorkStatusValue tone="info">{t('chat.workStatus.subagent.working')}</WorkStatusValue>
) : (
<WorkStatusValue tone="muted">{t('chat.workStatus.subagent.done')}</WorkStatusValue>
)}
/>
);
})}
</WorkStatusCollapsibleSection>
);
};
@@ -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<string, number> = {
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<Props> = ({ 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 (
<WorkStatusSection
title={t('chat.workStatus.section.tasks')}
summary={`${doneCount}/${visibleTodos.length}`}
>
{visibleTodos.map((todo, index) => {
const done = todo.status === 'completed';
const icon = statusIcon(todo.status);
return (
<Tooltip key={`${todo.status}-${index}-${todo.content}`} delayDuration={600}>
<TooltipTrigger asChild>
<div>
<WorkStatusRow
leading={(
<Icon
name={icon.name}
className="size-3.5 shrink-0"
style={icon.color ? { color: icon.color } : undefined}
/>
)}
muted={done}
label={<span className={done ? 'line-through' : undefined}>{todo.content}</span>}
/>
</div>
</TooltipTrigger>
{/* Rows truncate at this width; the tooltip is the only way to read
a long task in full. */}
<TooltipContent side="left" className="max-w-[320px]">
{todo.content}
</TooltipContent>
</Tooltip>
);
})}
</WorkStatusSection>
);
};
@@ -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 (
<WorkStatusCollapsibleSection
id="usage"
title={t('chat.workStatus.section.usage')}
icon="timer"
summary={(
<span className="inline-flex items-center gap-1.5">
{isLoading ? <Icon name="refresh" className="size-3 animate-spin" /> : null}
{headline && headlineMetric && headlineMetric !== '-' ? (
<>
<span className="truncate">{headline.row.label}</span>
<WorkStatusValue tone={windowTone(headline.row.window)}>{headlineMetric}</WorkStatusValue>
</>
) : modeLabel}
</span>
)}
>
{groups.map((group) => (
<React.Fragment key={group.providerId}>
<WorkStatusRow
leading={<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />}
label={group.providerName}
muted
value={group.status && group.rows.length === 0 ? (
<WorkStatusValue tone="muted">{group.status}</WorkStatusValue>
) : 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 (
<WorkStatusRow
key={`${group.providerId}-${row.key}`}
label={(
<span className="inline-flex min-w-0 items-baseline gap-1.5">
<span className="truncate">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 text-[11px] text-muted-foreground">{resetLabel}</span>
) : null}
</span>
)}
value={metricLabel === '-' ? undefined : (
<WorkStatusValue tone={windowTone(row.window)}>{metricLabel}</WorkStatusValue>
)}
/>
);
})}
</React.Fragment>
))}
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { computeContextUsage, DEFAULT_CONTEXT_LIMIT } from './contextUsage';
const assistant = (tokens: Record<string, unknown>, 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);
});
});
@@ -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;
};
@@ -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<string>());
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 <PresenceContext.Provider value={report}>{children}</PresenceContext.Provider>;
};
@@ -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]);
};
@@ -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([]);
});
});
@@ -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<WorkStatusSectionId, I18nKey> = {
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<string>(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<WorkStatusSectionId>();
for (const entry of value) {
if (isWorkStatusSectionId(entry)) seen.add(entry);
}
return [...seen];
};
@@ -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();
});
});
@@ -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<string, string>([
['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] };
};
@@ -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<string, PanelState> = {};
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<string, PropertyDescriptor | undefined>();
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<string, unknown> = {
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<typeof setTimeout>) => 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();
});
});
@@ -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<HTMLDivElement | null>(null);
const [rowWidth, setRowWidth] = React.useState<number | null>(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<HTMLElement>('[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 };
};
@@ -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<string> => {
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<string>('');
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalOrigin());
const [localOrigin, setLocalOrigin] = React.useState<string>(() => getLocalDesktopOrigin());
const [editingId, setEditingId] = React.useState<string | null>(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);
@@ -150,6 +150,7 @@ export const iconSpriteData = {
"link-unlink-m": `<path d="M17.657 14.8284L16.2428 13.4142L17.657 12C19.2191 10.4379 19.2191 7.90526 17.657 6.34316C16.0949 4.78106 13.5622 4.78106 12.0001 6.34316L10.5859 7.75737L9.17171 6.34316L10.5859 4.92895C12.9291 2.5858 16.7281 2.5858 19.0712 4.92895C21.4143 7.27209 21.4143 11.0711 19.0712 13.4142L17.657 14.8284ZM14.8286 17.6569L13.4143 19.0711C11.0712 21.4142 7.27221 21.4142 4.92907 19.0711C2.58592 16.7279 2.58592 12.9289 4.92907 10.5858L6.34328 9.17159L7.75749 10.5858L6.34328 12C4.78118 13.5621 4.78118 16.0948 6.34328 17.6569C7.90538 19.219 10.438 19.219 12.0001 17.6569L13.4143 16.2427L14.8286 17.6569ZM14.8286 7.75737L16.2428 9.17159L9.17171 16.2427L7.75749 14.8284L14.8286 7.75737ZM5.77539 2.29291L7.70724 1.77527L8.74252 5.63897L6.81067 6.15661L5.77539 2.29291ZM15.2578 18.3611L17.1896 17.8434L18.2249 21.7071L16.293 22.2248L15.2578 18.3611ZM2.29303 5.77527L6.15673 6.81054L5.63909 8.7424L1.77539 7.70712L2.29303 5.77527ZM18.3612 15.2576L22.2249 16.2929L21.7072 18.2248L17.8435 17.1895L18.3612 15.2576Z" fill="currentColor"/>`,
"list-check-2": `<path d="M11 4H21V6H11V4ZM11 8H17V10H11V8ZM11 14H21V16H11V14ZM11 18H17V20H11V18ZM3 4H9V10H3V4ZM5 6V8H7V6H5ZM3 14H9V20H3V14ZM5 16V18H7V16H5Z" fill="currentColor"/>`,
"list-check-3": `<path d="M8.00008 6V9H5.00008V6H8.00008ZM3.00008 4V11H10.0001V4H3.00008ZM13.0001 4H21.0001V6H13.0001V4ZM13.0001 11H21.0001V13H13.0001V11ZM13.0001 18H21.0001V20H13.0001V18ZM10.7072 16.2071L9.29297 14.7929L6.00008 18.0858L4.20718 16.2929L2.79297 17.7071L6.00008 20.9142L10.7072 16.2071Z" fill="currentColor"/>`,
"list-indefinite": `<path d="M13 4H21V6H13V4ZM13 11H21V13H13V11ZM13 18H21V20H13V18ZM6.5 19C5.39543 19 4.5 18.1046 4.5 17C4.5 15.8954 5.39543 15 6.5 15C7.60457 15 8.5 15.8954 8.5 17C8.5 18.1046 7.60457 19 6.5 19ZM6.5 21C8.70914 21 10.5 19.2091 10.5 17C10.5 14.7909 8.70914 13 6.5 13C4.29086 13 2.5 14.7909 2.5 17C2.5 19.2091 4.29086 21 6.5 21ZM5 6V9H8V6H5ZM3 4H10V11H3V4Z" fill="currentColor"/>`,
"list-unordered": `<path d="M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z" fill="currentColor"/>`,
"loader": `<path d="M11.9995 2C12.5518 2 12.9995 2.44772 12.9995 3V6C12.9995 6.55228 12.5518 7 11.9995 7C11.4472 7 10.9995 6.55228 10.9995 6V3C10.9995 2.44772 11.4472 2 11.9995 2ZM11.9995 17C12.5518 17 12.9995 17.4477 12.9995 18V21C12.9995 21.5523 12.5518 22 11.9995 22C11.4472 22 10.9995 21.5523 10.9995 21V18C10.9995 17.4477 11.4472 17 11.9995 17ZM20.6597 7C20.9359 7.47829 20.772 8.08988 20.2937 8.36602L17.6956 9.86602C17.2173 10.1422 16.6057 9.97829 16.3296 9.5C16.0535 9.02171 16.2173 8.41012 16.6956 8.13398L19.2937 6.63397C19.772 6.35783 20.3836 6.52171 20.6597 7ZM7.66935 14.5C7.94549 14.9783 7.78161 15.5899 7.30332 15.866L4.70525 17.366C4.22695 17.6422 3.61536 17.4783 3.33922 17C3.06308 16.5217 3.22695 15.9101 3.70525 15.634L6.30332 14.134C6.78161 13.8578 7.3932 14.0217 7.66935 14.5ZM20.6597 17C20.3836 17.4783 19.772 17.6422 19.2937 17.366L16.6956 15.866C16.2173 15.5899 16.0535 14.9783 16.3296 14.5C16.6057 14.0217 17.2173 13.8578 17.6956 14.134L20.2937 15.634C20.772 15.9101 20.9359 16.5217 20.6597 17ZM7.66935 9.5C7.3932 9.97829 6.78161 10.1422 6.30332 9.86602L3.70525 8.36602C3.22695 8.08988 3.06308 7.47829 3.33922 7C3.61536 6.52171 4.22695 6.35783 4.70525 6.63397L7.30332 8.13398C7.78161 8.41012 7.94549 9.02171 7.66935 9.5Z" fill="currentColor"/>`,
"loader-4": `<path d="M18.364 5.63604L16.9497 7.05025C15.683 5.7835 13.933 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12H21C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C14.4853 3 16.7353 4.00736 18.364 5.63604Z" fill="currentColor"/>`,
@@ -120,7 +120,14 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
) : displayBadgeCount ? (
<span
aria-hidden="true"
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
// Muted digits on the muted surface sat at almost the same
// luminance as the glyph they overlap. The count is a live
// signal, so it takes the info tone on its own opaque chip.
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold leading-none"
style={{
backgroundColor: 'var(--status-info-background)',
color: 'var(--status-info)',
}}
>
{displayBadgeCount}
</span>
@@ -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 (
<ContextPanelRailItem
+116 -398
View File
@@ -47,7 +47,6 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import {
getAllModelFamilies,
getDisplayModelName,
@@ -65,16 +64,21 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop';
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts';
import {
LOCAL_HOST_ID,
buildLocalDesktopHost,
getLocalDesktopOrigin,
resolveCurrentDesktopHost,
} from '@/lib/desktopCurrentHost';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { useShallow } from 'zustand/react/shallow';
import type { IconName } from "@/components/icon/icons";
import { toast } from '@/components/ui';
@@ -269,32 +273,11 @@ type DesktopServicesMenuProps = {
isDesktopServicesOpen: boolean;
setIsDesktopServicesOpen: React.Dispatch<React.SetStateAction<boolean>>;
refreshCurrentInstanceLabel: () => Promise<void>;
desktopServicesTab: 'instance' | 'usage' | 'mcp';
setDesktopServicesTab: React.Dispatch<React.SetStateAction<'instance' | 'usage' | 'mcp'>>;
quotaResultsLength: number;
fetchAllQuotas: () => Promise<unknown>;
servicesTabItems: SortableTabsStripItem[];
quotaLastUpdated: number | null;
quotaDisplayMode: 'usage' | 'remaining';
quotaDisplayTabItems: SortableTabsStripItem[];
handleDisplayModeChange: (mode: 'usage' | 'remaining') => Promise<void>;
handleUsageRefresh: () => void;
isQuotaLoading: boolean;
isUsageRefreshSpinning: boolean;
hasRateLimits: boolean;
rateLimitGroups: RateLimitGroup[];
expandedFamilies: Record<string, string[]>;
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
shortcutLabel: (actionId: string) => string;
showDevShutdown: boolean;
isDevShutdownInFlight: boolean;
onDevShutdown: () => Promise<void>;
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'
)}
>
<Icon name="stack" className="h-[18px] w-[18px]" />
<Icon name="server" className="h-[18px] w-[18px]" />
{isDesktopApp ? (
<span className="truncate typography-ui-label font-medium text-foreground">{compactCurrentInstanceLabel}</span>
) : null}
@@ -368,16 +327,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</TooltipTrigger>
<TooltipContent>
<p>
{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'),
})}
</p>
</TooltipContent>
</Tooltip>
@@ -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"
>
<div className="sticky top-0 z-20 px-2 pt-1.5 pb-px">
<div className="h-9">
<SortableTabsStrip
items={servicesTabItems}
activeId={desktopServicesTab}
onSelect={(tabID) => {
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"
/>
</div>
</div>
{isDesktopApp && desktopServicesTab === 'instance' ? (
{isDesktopApp ? (
<div>
{!currentInstanceIsLocal ? (
<div className="border-b border-[var(--interactive-border)] px-4 py-2.5">
@@ -435,185 +367,13 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
) : null}
<DesktopHostSwitcherDialog
embedded
open={isDesktopServicesOpen && desktopServicesTab === 'instance'}
open={isDesktopServicesOpen}
onOpenChange={() => {}}
onHostSwitched={() => setIsDesktopServicesOpen(false)}
/>
</div>
) : null}
{desktopServicesTab === 'mcp' ? (
<McpDropdownContent active={isDesktopServicesOpen && desktopServicesTab === 'mcp'} />
) : null}
{desktopServicesTab === 'usage' ? (
<div className="overflow-x-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--interactive-border)] px-4 py-2.5">
<div className="flex min-w-0 items-baseline gap-2">
<span className="typography-ui-header font-semibold text-foreground">{t('header.services.rateLimits')}</span>
<span className="truncate typography-micro text-muted-foreground">{formatTime(quotaLastUpdated, timeFormatPreference)}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-7 w-[10.5rem]">
<SortableTabsStrip
items={quotaDisplayTabItems}
activeId={quotaDisplayMode}
onSelect={(tabID) => void handleDisplayModeChange(tabID as 'usage' | 'remaining')}
layoutMode="fit"
variant="active-pill"
activePillInsetClassName="gap-0.5 px-px py-0"
className="h-full"
/>
</div>
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label={t('header.services.refreshRateLimitsAria')}
>
<Icon name="refresh" className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
</div>
</div>
{!hasRateLimits ? (
<div className="px-4 py-5 text-center">
<span className="typography-ui-label text-muted-foreground">{t('header.services.noRateLimits')}</span>
</div>
) : null}
{/* One elevated card per provider (same card language as the mobile
usage popover) instead of a flat run of divider-separated rows. */}
<div className="space-y-2 px-3 py-2.5">
{rateLimitGroups.map((group) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-3">
<div className="flex items-center gap-2 pb-2">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
<span className="typography-ui-label font-medium text-foreground">{group.providerName}</span>
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div>
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
</div>
) : (
<div className="space-y-3">
{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 (
<div key={`${group.providerId}-${label}`} className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<span className="truncate typography-ui-label text-foreground">{formatWindowLabel(label)}</span>
{resetLabel ? (
<span className="truncate typography-micro text-muted-foreground">
{resetLabel}
</span>
) : null}
</div>
<span className="typography-ui-label tabular-nums text-foreground">
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues ? <PaceIndicator paceInfo={paceInfo} compact /> : null}
</div>
);
})}
{group.modelFamilies && group.modelFamilies.length > 0 ? (
<div className="space-y-0.5">
{group.modelFamilies.map((family) => {
const familyKey = family.familyId ?? 'other';
const isExpanded = providerExpandedFamilies.includes(familyKey);
return (
<Collapsible
key={familyKey}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, familyKey)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md px-1 py-1.5 text-left hover:bg-[var(--interactive-hover)]/50 transition-colors">
<span className="typography-ui-label font-medium text-foreground">{family.familyLabel}</span>
{isExpanded ? <Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground" /> : <Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2.5 pb-1 pl-1 pt-1">
{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 (
<div key={`${group.providerId}-${modelName}`} className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{getDisplayModelName(modelName)}</span>
<span className="typography-ui-label tabular-nums text-foreground">
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues ? <PaceIndicator paceInfo={paceInfo} compact /> : null}
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
) : null}
</div>
)}
</div>
);
})}
</div>
</div>
) : null}
{showDevShutdown ? (
<>
<div className="mx-4 my-2 border-t border-[var(--interactive-border)]" />
<div className="px-2 pb-2">
<DropdownMenuItem
disabled={isDevShutdownInFlight}
onSelect={() => {
void onDevShutdown();
}}
>
{t('header.services.shutdownDev')}
</DropdownMenuItem>
</div>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -740,7 +500,6 @@ export const Header: React.FC<HeaderProps> = ({
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<HeaderProps> = ({
const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false);
const [remoteUpdateError, setRemoteUpdateError] = React.useState<string | null>(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<HeaderProps> = ({
}
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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
}
}, [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: <Icon name="server" className="h-3.5 w-3.5" /> });
}
base.push(
{ value: 'usage', label: t('layout.services.usage'), icon: <Icon name="timer" className="h-3.5 w-3.5" /> },
{ value: 'mcp', label: 'MCP', icon: <McpIcon className="h-3.5 w-3.5" /> }
);
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<SortableTabsStripItem[]>(() => {
return [
@@ -2072,31 +1779,19 @@ export const Header: React.FC<HeaderProps> = ({
} 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<HeaderProps> = ({
}, [
shortcutOverrides,
isDesktopServicesOpen,
desktopServicesTab,
servicesTabs,
quotaResults.length,
fetchAllQuotas,
@@ -2172,6 +1866,10 @@ export const Header: React.FC<HeaderProps> = ({
const desktopSidebarActions = (
<>
<OpenInAppButton directory={actionDirectory} className="mr-1" />
{/* 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 ? (
<DesktopServicesMenu
isDesktopApp={isDesktopApp}
currentInstanceLabel={currentInstanceLabel}
@@ -2180,33 +1878,13 @@ export const Header: React.FC<HeaderProps> = ({
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}
<DesktopGitHubControl
isMobile={isMobile}
githubAuthStatus={githubAuthStatus}
@@ -2321,7 +1999,7 @@ export const Header: React.FC<HeaderProps> = ({
{isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle}
</span>
)}
{(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? (
{showHeaderMetaRow ? (
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{activeProjectLabel ? <span className="truncate">{activeProjectLabel}</span> : null}
{currentBranchLabel ? (
@@ -2342,7 +2020,12 @@ export const Header: React.FC<HeaderProps> = ({
</span>
) : null}
</div>
<div className="flex h-[18px] shrink-0 items-center justify-center self-start">
<div className={cn(
'flex h-[18px] shrink-0 items-center justify-center',
// Top-aligned only when the title has a metadata line under it;
// alone, the title is centred and the button must follow.
showHeaderMetaRow ? 'self-start' : 'self-center',
)}>
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? (
<DropdownMenu
open={isHeaderSessionMenuOpen}
@@ -2431,6 +2114,7 @@ export const Header: React.FC<HeaderProps> = ({
percentIconClassName="h-4.5 w-4.5"
/>
) : null}
<HeaderIconActionButton
visible={showMiniChatHeaderAction}
title={isNewSessionDraftOpen ? t('header.actions.newMiniChat') : t('header.actions.openSessionMiniChat')}
@@ -2439,6 +2123,40 @@ export const Header: React.FC<HeaderProps> = ({
className={cn(desktopHeaderIconButtonClass, 'mr-1')}
Icon={'picture-in-picture-2'}
/>
{activeMainTab === 'chat' && !isVSCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-work-status-toggle="true"
aria-pressed={workStatusToggleActive}
aria-label={t('header.workStatusPanel.toggleAria')}
onClick={handleWorkStatusToggle}
className={cn(
DESKTOP_HEADER_ICON_BUTTON_CLASS,
// Trailing gap before the sidebar actions; it moved here
// with the button when this took the last position.
'mr-1',
// On is the resting state and carries no chrome; off is the
// one worth signalling, so it dims instead of filling.
workStatusToggleActive ? 'text-foreground' : 'text-muted-foreground/50',
)}
>
<Icon name="list-indefinite" className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{workStatusPanelEnabled && !workStatusPanelFits
? (workStatusOverlayOpen
? t('header.workStatusPanel.hide')
: t('header.workStatusPanel.showOverlay'))
: workStatusPanelEnabled
? t('header.workStatusPanel.hide')
: t('header.workStatusPanel.show')}
</TooltipContent>
</Tooltip>
) : null}
{desktopSidebarActions}
<WindowsWindowControls visible={usesFramelessChrome && windowControlsSide === 'right'} position="right" />
</div>
@@ -437,7 +437,11 @@ export const MainLayout: React.FC = () => {
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
{/* 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. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
@@ -128,7 +128,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ isOpen, isMobile, children, cl
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className="pointer-events-none absolute inset-0 z-30 shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]"
aria-hidden="true"
/>
)}
{isOpen && (
<div
className={cn(
+23 -3
View File
@@ -21,6 +21,9 @@ import { computeMcpHealth, useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { toast } from 'sonner';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
const statusTooltip = (
status: McpStatus | undefined,
@@ -196,11 +199,28 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
onCheckedChange={async (checked) => {
setBusyName(serverName);
try {
if (checked) {
await connect(serverName, directory);
} else {
if (!checked) {
await disconnect(serverName, directory);
return;
}
// Reconnecting a server that is waiting on authorization
// just repeats the attempt that produced `needs_auth`;
// the user has to visit the provider first.
const entryStatus = status?.[serverName]?.status;
if (entryStatus === 'needs_auth' || entryStatus === 'needs_client_registration') {
const { opened } = await startMcpAuthorization({
name: serverName,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('mcpDropdown.toast.authorizeOpenFailed'));
}
return;
}
await connect(serverName, directory);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('mcpDropdown.toast.authorizeFailed'));
} finally {
setBusyName(null);
}
@@ -2,10 +2,29 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { MCP_OAUTH_ORIGIN_DESKTOP } from '@/components/sections/mcp/startMcpAuthorization';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PAGE_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
import { cn } from '@/lib/utils';
/**
* Handing control back after the browser finished the authorization.
*
* This page always runs in a browser, but the flow may have been started from
* the desktop shell a different surface entirely. Sending that user to `/`
* would raise a second copy of the interface in a tab while the real app sits
* behind it, so the desktop case is returned through its own protocol, which
* focuses the running window.
*/
const returnToApp = (startedFromDesktop: boolean): void => {
if (typeof window === 'undefined') return;
if (startedFromDesktop) {
window.location.href = 'openchamber://focus/mcp-auth';
return;
}
window.location.replace('/');
};
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
if (typeof value !== 'string') {
@@ -27,6 +46,7 @@ const normalizeMcpAuthErrorMessage = (error: unknown, fallback: string): string
export const McpOAuthCallbackPage: React.FC = () => {
const completeAuth = useMcpStore((state) => state.completeAuth);
const [status, setStatus] = React.useState<'working' | 'success' | 'error'>('working');
const [returnToDesktop, setReturnToDesktop] = React.useState(false);
const [message, setMessage] = React.useState('Completing MCP authorization...');
React.useEffect(() => {
@@ -59,11 +79,21 @@ export const McpOAuthCallbackPage: React.FC = () => {
}
let pendingContext = callbackContext;
if (!pendingContext && callbackStateKey) {
let startedFromDesktop = false;
// Always consulted, even when the state already carries the server:
// the origin lives only here, and it decides where the user is sent
// back to.
if (callbackStateKey) {
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
if (response.ok) {
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
if (payload?.name?.trim()) {
const payload = await response.json().catch(() => null) as {
name?: string;
directory?: string | null;
origin?: string | null;
} | null;
startedFromDesktop = payload?.origin === MCP_OAUTH_ORIGIN_DESKTOP;
setReturnToDesktop(startedFromDesktop);
if (!pendingContext && payload?.name?.trim()) {
pendingContext = {
name: payload.name.trim(),
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
@@ -81,6 +111,12 @@ export const McpOAuthCallbackPage: React.FC = () => {
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('success');
// Attempted straight away: the user's attention is in a browser tab,
// and the app they were working in is behind it. The button below
// stays as the fallback for a browser that blocks the protocol jump.
if (startedFromDesktop) {
returnToApp(true);
}
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
} catch (authError) {
if (callbackStateKey) {
@@ -117,12 +153,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
<div className="mt-8 flex justify-center">
<Button
type="button"
onClick={() => {
if (typeof window === 'undefined') {
return;
}
window.location.replace('/');
}}
onClick={() => returnToApp(returnToDesktop)}
>
Return to OpenChamber
</Button>
@@ -20,21 +20,20 @@ import {
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SettingsFieldRow,
SettingsCheckboxRow,
SettingsStackedField,
SettingsChipGroup,
SettingsGroupTitle,
SettingsStackedField,
SETTINGS_SELECT_SIZE,
SETTINGS_FIELD_LABEL_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Dialog,
@@ -51,6 +50,7 @@ import {
SelectTrigger,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import { useI18n } from '@/lib/i18n';
// ─────────────────────────────────────────────────────────────
@@ -59,11 +59,9 @@ import { useI18n } from '@/lib/i18n';
interface CommandTextareaProps {
value: string[];
onChange: (v: string[]) => void;
pasteCommandTitle: string;
pasteCommandLabel: string;
pasteSuccess: (count: number) => string;
clipboardReadFailed: string;
preview: (count: number) => string;
/** Called when the text is plainly a link rather than a command. */
onDetectUrl?: (url: string) => void;
}
/**
@@ -124,11 +122,8 @@ function extractAuthorizationResponse(raw: string): {
const CommandTextarea: React.FC<CommandTextareaProps> = ({
value,
onChange,
pasteCommandTitle,
pasteCommandLabel,
pasteSuccess,
clipboardReadFailed,
preview,
onDetectUrl,
}) => {
// Internal: one arg per line
const [text, setText] = React.useState(() => value.join('\n'));
@@ -144,42 +139,38 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
const commit = (raw: string) => {
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
// A single line that is nothing but a URL is a hosted server, not a
// command to run — the page switches kind rather than making the user say.
if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) {
onDetectUrl(lines[0].trim());
return;
}
onChange(lines);
};
const handlePasteFromClipboard = async () => {
try {
const raw = await navigator.clipboard.readText();
const trimmed = raw.trim();
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
const lines = trimmed.includes('\n')
? trimmed.split('\n').filter((l) => l.trim())
: parseShellCommand(trimmed);
setText(lines.join('\n'));
onChange(lines);
toast.success(pasteSuccess(lines.length));
} catch {
toast.error(clipboardReadFailed);
}
/**
* Pasting a whole command line splits it into arguments here, in the field
* the user pasted into. The old approach a button that read the clipboard
* itself fails outright wherever the runtime denies clipboard reads.
*/
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const raw = event.clipboardData.getData('text');
const trimmed = raw.trim();
// Only take over a paste that replaces the whole field with one command
// line; anything else is ordinary editing and belongs to the browser.
if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return;
const target = event.currentTarget;
if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return;
event.preventDefault();
const lines = parseShellCommand(trimmed);
setText(lines.join('\n'));
onChange(lines);
};
return (
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1 text-muted-foreground"
onClick={handlePasteFromClipboard}
type="button"
title={pasteCommandTitle}
>
<Icon name="clipboard" className="h-3 w-3" />
{pasteCommandLabel}
</Button>
</div>
<Textarea
onPaste={handlePaste}
value={text}
onChange={(e) => {
setText(e.target.value);
@@ -198,7 +189,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
}
rows={Math.max(4, value.length + 1)}
className="font-mono typography-meta resize-y min-h-[80px]"
className="font-mono typography-meta min-h-[80px]"
spellCheck={false}
/>
@@ -508,42 +499,6 @@ const shouldShowFullStatusCard = (status: string | undefined, authUrl: string |
return false;
};
const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | null): string | null => {
if (typeof window === 'undefined') {
return null;
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
if (typeof name === 'string' && name.trim()) {
url.searchParams.set('server', name.trim());
}
if (typeof directory === 'string' && directory.trim()) {
url.searchParams.set('directory', directory.trim());
}
return url.toString();
};
const queuePendingMcpAuthContext = async (input: {
state: string;
name: string;
directory?: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: typeof input.directory === 'string' && input.directory.trim() ? input.directory.trim() : null,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error || 'Failed to prepare MCP authorization callback');
}
};
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
if (!response.ok) {
@@ -626,7 +581,6 @@ export const McpPage: React.FC = () => {
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
const startAuthMcp = useMcpStore((state) => state.startAuth);
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
const testConnectionMcp = useMcpStore((state) => state.testConnection);
@@ -887,6 +841,39 @@ export const McpPage: React.FC = () => {
);
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
// What the user has is either a command they were given or a link. Which of
// the two decides the transport, so the page reads it off the text instead of
// asking — and lets them correct it when the text alone cannot say.
const connectionKindTabs = React.useMemo<SortableTabsStripItem[]>(() => [
{
id: 'local',
label: t('settings.mcp.page.connection.kindCommand'),
icon: <Icon name="terminal" className="h-3.5 w-3.5" />,
},
{
id: 'remote',
label: t('settings.mcp.page.connection.kindLink'),
icon: <Icon name="global" className="h-3.5 w-3.5" />,
},
], [t]);
const handleDetectedUrl = React.useCallback((candidate: string) => {
setMcpType('remote');
setUrl(candidate);
setCommand([]);
}, []);
const handleUrlChange = React.useCallback((next: string) => {
setUrl(next);
// A command pasted into the link field is still a command.
const trimmed = next.trim();
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /\s/.test(trimmed)) {
setMcpType('local');
setCommand(parseShellCommand(trimmed));
setUrl('');
}
}, []);
const handleSave = async () => {
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
@@ -1042,48 +1029,20 @@ export const McpPage: React.FC = () => {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
const redirectUri = buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
if (!redirectUri) {
throw new Error(t('settings.mcp.page.toast.oauthRedirectUrlBuildFailed'));
}
if (!oauthRedirectUri.trim() && !isVSCodeAuthRuntime) {
const saved = await updateMcp(selectedMcpName, {
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri: redirectUri,
});
if (!saved.ok) {
throw new Error(t('settings.mcp.page.toast.oauthBrowserCallbackSaveFailed'));
}
if (saved.reloadFailed) {
throw new Error(saved.warning || saved.message || t('settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave'));
}
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
setOauthRedirectUri(redirectUri);
initialRef.current = initialRef.current
? { ...initialRef.current, oauthRedirectUri: redirectUri }
: initialRef.current;
}
const nextAuthUrl = await startAuthMcp(selectedMcpName, currentDirectory);
// One implementation for every surface that can authorise; the page
// used to own this flow while the dropdown and the work-status panel
// called plain `connect`, which cannot start OAuth at all.
const { authorizationUrl: nextAuthUrl, opened } = await startMcpAuthorization({
name: selectedMcpName,
directory: currentDirectory,
// Only VS Code keeps OpenCode's own redirect. Skipping whenever some
// value was stored left a stale one — a dead loopback port from an
// earlier launch — unrepairable from this page; the bootstrap already
// rewrites nothing when the stored value is right.
skipRedirectUriBootstrap: isVSCodeAuthRuntime,
});
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
if (stateKey) {
queuedStateKey = stateKey;
await queuePendingMcpAuthContext({
state: stateKey,
name: selectedMcpName,
directory: currentDirectory,
});
}
queuedStateKey = stateKey;
if (runtimeActionKeyRef.current !== actionKey) {
return;
@@ -1094,7 +1053,6 @@ export const McpPage: React.FC = () => {
setIsAuthPolling(true);
authPollAttemptsRef.current = 0;
const opened = await openExternalUrl(nextAuthUrl);
if (runtimeActionKeyRef.current !== actionKey) {
return;
}
@@ -1118,7 +1076,7 @@ export const McpPage: React.FC = () => {
setIsAuthorizing(false);
}
}
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, oauthClientId, oauthClientSecret, oauthEnabled, oauthRedirectUri, oauthScope, requireSavedConfig, runtimeActionKey, selectedMcpName, startAuthMcp, t, tUnsafe, updateMcp]);
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
const handleClearAuthorization = React.useCallback(async () => {
if (!selectedMcpName || !requireSavedConfig()) return;
@@ -1313,7 +1271,25 @@ export const McpPage: React.FC = () => {
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
const isConnected = runtimeStatus?.status === 'connected';
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
const suggestedRedirectUri = isVSCodeAuthRuntime ? null : buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
// Must be the very URI `startMcpAuthorization` writes into the config, not a
// second construction of it. The page used to suggest a directory-bearing
// address while the flow sent a directory-less one, so a provider enforcing
// exact redirect matching rejected a registration copied from right here.
const suggestedRedirectUri = isVSCodeAuthRuntime || !selectedMcpName
? null
: buildMcpAuthorizationRedirectUri(selectedMcpName);
const handleCopyRedirectUri = async () => {
if (!suggestedRedirectUri) return;
try {
await navigator.clipboard.writeText(suggestedRedirectUri);
toast.success(t('settings.mcp.page.toast.copiedCallbackUrl'));
} catch {
toast.error(t('settings.mcp.page.toast.clipboardWriteFailed'));
}
};
const runtimeDescription = getStatusDescription(
effectiveRuntimeStatus?.status,
tUnsafe,
@@ -1431,6 +1407,69 @@ export const McpPage: React.FC = () => {
)}
</div>
{/* Client credentials appear only when the server has said it
needs them. Kept as a permanent four-field form, they made
the rarest case the most prominent thing on the page and
told nobody what to put there. */}
{effectiveRuntimeStatus?.status === 'needs_client_registration' && (
<div className="space-y-3 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div>
<SettingsGroupTitle as="div">{t('settings.mcp.page.registration.title')}</SettingsGroupTitle>
<p className="mt-1 typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.description')}
</p>
</div>
{suggestedRedirectUri && (
<div>
<div className="typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.callbackLabel')}
</div>
<div className="mt-1 flex items-start gap-2">
<span className="min-w-0 flex-1 break-all font-mono typography-micro text-foreground/80">
{suggestedRedirectUri}
</span>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleCopyRedirectUri()}
>
<Icon name="clipboard" className="h-3.5 w-3.5" />
{t('settings.mcp.page.actions.copyLink')}
</Button>
</div>
</div>
)}
<div className="grid gap-3 @xl:grid-cols-2">
<SettingsStackedField label={t('settings.mcp.page.registration.clientId')}>
<Input
value={oauthClientId}
onChange={(e) => { setOauthClientId(e.target.value); setOauthEnabled(true); }}
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
/>
</SettingsStackedField>
<SettingsStackedField label={t('settings.mcp.page.registration.clientSecret')}>
<Input
type="password"
value={oauthClientSecret}
onChange={(e) => { setOauthClientSecret(e.target.value); setOauthEnabled(true); }}
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
/>
</SettingsStackedField>
</div>
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.registration.afterSaving')}
</p>
</div>
)}
{authUrl && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
<div className="space-y-2">
@@ -1450,7 +1489,11 @@ export const McpPage: React.FC = () => {
</div>
)}
{mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
{/* VS Code only. Everywhere else the callback returns into the
app on its own, so the paste box was a second, confusing way
to do what already happened. VS Code cannot receive that
redirect, so there it remains the only way to finish. */}
{isVSCodeAuthRuntime && mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
<div className="space-y-2">
<div>
@@ -1464,7 +1507,7 @@ export const McpPage: React.FC = () => {
onChange={(event) => setAuthCallbackInput(event.target.value)}
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
rows={3}
className="font-mono typography-meta resize-y"
className="font-mono typography-meta"
data-bwignore="true"
data-1p-ignore="true"
spellCheck={false}
@@ -1499,32 +1542,60 @@ export const McpPage: React.FC = () => {
divider={false}
settingsItem="mcp.server"
contentClassName="space-y-0"
titleAccessory={isNewServer ? (
<Button
variant="ghost"
size="xs"
className="!font-normal gap-1.5 text-muted-foreground"
onClick={handleOpenImportDialog}
type="button"
title={t('settings.mcp.page.server.importJsonTitle')}
>
<Icon name="file-code" className="h-3.5 w-3.5" />
{t('settings.mcp.page.server.importJson')}
</Button>
) : null}
>
{isNewServer && (
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
<SettingsFieldRow
label={t('settings.mcp.page.server.name')}
// The scope select carries words now, not a lone icon, so the
// control cluster has to be allowed to bound itself and wrap.
// Left at its default (fit-width, no shrink) the pair ran past
// the edge of the settings pane in a narrow dialog.
controlClassName="flex-wrap @xl:w-auto @xl:flex-1"
>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
placeholder={t('settings.mcp.page.server.namePlaceholder')}
className="h-7 w-48 font-mono px-2"
className="h-7 w-48 min-w-0 max-w-full shrink font-mono px-2"
autoFocus
/>
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 min-w-0 max-w-full gap-1.5 px-2">
<Icon
name={draftScope === 'user' ? 'user-3' : 'folder'}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="truncate">
{draftScope === 'user'
? t('settings.mcp.page.scope.everywhere')
: t('settings.mcp.page.scope.thisProject')}
</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user">
<div className="flex items-center gap-2">
<Icon name="user-3" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.global')}</span>
<span>{t('settings.mcp.page.scope.everywhere')}</span>
</div>
</SelectItem>
<SelectItem value="project">
<div className="flex items-center gap-2">
<Icon name="folder" className="h-3.5 w-3.5" />
<span>{t('settings.common.scope.project')}</span>
<span>{t('settings.mcp.page.scope.thisProject')}</span>
</div>
</SelectItem>
</SelectContent>
@@ -1532,23 +1603,6 @@ export const McpPage: React.FC = () => {
</SettingsFieldRow>
)}
{/* Import JSON - prominent placement for new servers */}
{isNewServer && (
<div className="py-1.5">
<Button
variant="outline"
size="xs"
className="!font-normal gap-1.5"
onClick={handleOpenImportDialog}
type="button"
title={t('settings.mcp.page.server.importJsonTitle')}
>
<Icon name="file-code" className="h-3.5 w-3.5" />
{t('settings.mcp.page.server.importJson')}
</Button>
</div>
)}
<SettingsCheckboxRow
checked={enabled}
onChange={setEnabled}
@@ -1556,42 +1610,50 @@ export const McpPage: React.FC = () => {
ariaLabel={t('settings.mcp.page.server.enableAria')}
/>
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
<SettingsChipGroup
aria-label={t('settings.mcp.page.server.transportMode')}
value={mcpType}
onChange={setMcpType}
options={[
{ value: 'local', label: t('settings.mcp.page.transport.local') },
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
]}
/>
</SettingsStackedField>
</SettingsSection>
<SettingsSection
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
title={t('settings.mcp.page.connection.title')}
description={t('settings.mcp.page.connection.description')}
settingsItem="mcp.command"
// The section's content wrapper carries no spacing of its own, so the
// kind tabs, the field and its hint would otherwise sit flush.
contentClassName="space-y-2"
>
{/* Pasting a link or a command still flips this for you, but the
choice is a control you can see and press. As one sentence with
an inline link it was, in practice, undiscoverable. */}
<SortableTabsStrip
items={connectionKindTabs}
activeId={mcpType}
onSelect={(id) => setMcpType(id as 'local' | 'remote')}
layoutMode="fit"
variant="active-pill"
activePillLowercase={false}
className="h-10"
/>
{mcpType === 'local' ? (
<CommandTextarea
value={command}
onChange={setCommand}
pasteCommandTitle={t('settings.mcp.page.connection.pasteCommandTitle')}
pasteCommandLabel={t('settings.mcp.page.connection.pasteCommand')}
pasteSuccess={(count) => t('settings.mcp.page.toast.pastedArgumentsCount', { count })}
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
onDetectUrl={handleDetectedUrl}
/>
) : (
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
className="font-mono typography-meta"
/>
)}
<p className="typography-micro text-muted-foreground">
{mcpType === 'local'
? t('settings.mcp.page.connection.hintCommand')
: t('settings.mcp.page.connection.hintLink')}
</p>
</SettingsSection>
{mcpType === 'remote' && (
@@ -1607,7 +1669,9 @@ export const McpPage: React.FC = () => {
<div className="flex items-center gap-1.5 text-left">
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
<span className="typography-micro text-muted-foreground">
({oauthEnabled ? t('settings.mcp.page.advanced.autoDetect') : t('settings.mcp.page.advanced.custom')} · {headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
{/* OAuth left the form, so the summary stops reporting a
setting the user can no longer see. */}
({headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
</span>
</div>
{isAdvancedRemoteOptionsOpen ? (
@@ -1669,61 +1733,6 @@ export const McpPage: React.FC = () => {
/>
</div>
<div className="space-y-3">
<SettingsCheckboxRow
checked={oauthEnabled}
onChange={setOauthEnabled}
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
info={t('settings.mcp.page.advanced.oauthHint')}
/>
<div className="grid gap-3 @xl:grid-cols-2">
<Input
value={oauthClientId}
onChange={(e) => setOauthClientId(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientIdPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthClientSecret}
onChange={(e) => setOauthClientSecret(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthClientSecretPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthScope}
onChange={(e) => setOauthScope(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthScopesPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
<Input
value={oauthRedirectUri}
onChange={(e) => setOauthRedirectUri(e.target.value)}
placeholder={t('settings.mcp.page.advanced.oauthRedirectUriPlaceholder')}
className="font-mono typography-meta"
disabled={!oauthEnabled}
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
{suggestedRedirectUri && (
<p className="typography-micro text-muted-foreground">
{t('settings.mcp.page.advanced.oauthCallbackHint')}
<span className="mt-1 block break-all font-mono text-foreground/80">{suggestedRedirectUri}</span>
</p>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
@@ -1732,6 +1741,7 @@ export const McpPage: React.FC = () => {
<SettingsSection
title={t('settings.mcp.page.env.title')}
description={t('settings.mcp.page.env.description')}
titleAccessory={
envEntries.length > 0 ? (
<span className="typography-micro text-muted-foreground font-normal">
@@ -1823,7 +1833,7 @@ export const McpPage: React.FC = () => {
}}
placeholder={'{\n "mcpServers": {\n "postgres": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-postgres"]\n }\n }\n}'}
rows={8}
className="font-mono typography-meta resize-y"
className="font-mono typography-meta"
spellCheck={false}
data-bwignore="true"
data-1p-ignore="true"
@@ -0,0 +1,212 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { openExternalUrl } from '@/lib/url';
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
/**
* Starting MCP authorization, for every surface that offers it.
*
* A server in `needs_auth` cannot be fixed by reconnecting: `POST /mcp/:name/connect`
* just repeats the attempt that produced `needs_auth` in the first place. The
* flow OpenCode expects is explicit ask for an authorization URL, send the
* user to it, then hand the returned code back:
*
* POST /mcp/:name/auth { authorizationUrl, oauthState }
* (user authorises in a browser)
* POST /mcp/:name/auth/callback status
*
* OpenCode does not open the browser for this flow; that is the caller's job.
*
* The redirect URI matters as much as the call. Without one of ours in the
* server's config, OpenCode falls back to its own loopback listener on
* 127.0.0.1 which only works when the browser runs on the same machine as
* the OpenCode process. For a remote or web client the callback would simply
* never arrive, so the first authorization writes our own callback URL into
* the config before asking for the URL.
*/
type McpAuthorizationStart = {
authorizationUrl: string;
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
opened: boolean;
};
class McpAuthorizationError extends Error {}
/**
* The callback lands in the system browser, which is a different surface from
* the desktop app. Recording where the flow began lets the callback page hand
* control back correctly: a browser session returns to the app it is already
* showing, while the desktop shell has to be raised through its own deep link.
*
* This travels with the pending context, not in the redirect URI. That URI is
* written into the server's config once and never rewritten, so a marker
* encoded there would be frozen at whatever runtime happened to authorise
* first a desktop user would keep being sent to the web UI forever.
*/
export const MCP_OAUTH_ORIGIN_DESKTOP = 'desktop';
/**
* Stable for a given server, whatever session is open.
*
* It used to carry the directory as well, which made the address different for
* every worktree: switching sessions produced a new value, so the config was
* rewritten and OpenCode reloaded in front of the user. The directory is not
* needed here authorization is not per-directory and the pending context
* parked under the OAuth `state` carries it for the completion call.
*
* The server name stays. It never varies for a given entry, since the redirect
* lives in that entry's own config, and it lets the callback page identify the
* server straight from the URL rather than depending solely on server-side
* memory surviving the reload this very write triggers.
*/
export const buildMcpAuthorizationRedirectUri = (name: string): string => {
if (typeof window === 'undefined') {
throw new McpAuthorizationError('No browser context to build a callback URL from');
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
url.searchParams.set('server', name);
return url.toString();
};
/**
* Correlates the eventual browser redirect with the server it belongs to. The
* callback page has only the OAuth `state` to go on, so the pair is parked
* server-side under that key.
*/
const queuePendingContext = async (input: {
state: string;
name: string;
directory?: string | null;
origin: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: input.directory?.trim() ? input.directory.trim() : null,
origin: input.origin,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new McpAuthorizationError(payload?.error || 'Failed to prepare the MCP authorization callback');
}
};
const clearPendingContext = async (state: string | null): Promise<void> => {
if (!state) return;
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(state)}`, { method: 'DELETE' })
.catch(() => undefined);
};
/** How long the user plausibly spends authorising before giving up on them. */
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
const AUTHORIZATION_POLL_MS = 1_500;
const waitForAuthorizationThenFocus = async (name: string, directory: string | null): Promise<void> => {
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, AUTHORIZATION_POLL_MS));
try {
await useMcpStore.getState().refresh({ directory, silent: true });
} catch {
continue;
}
const status = useMcpStore.getState().getStatusForDirectory(directory)[name]?.status;
if (status === 'connected') {
void focusDesktopWindow();
return;
}
}
};
export const startMcpAuthorization = async (input: {
name: string;
directory?: string | null;
/** VS Code cannot receive our callback route, so it keeps OpenCode's own redirect. */
skipRedirectUriBootstrap?: boolean;
}): Promise<McpAuthorizationStart> => {
const { name, directory } = input;
let queuedState: string | null = null;
try {
if (!input.skipRedirectUriBootstrap) {
// The config has to be loaded before its absence can mean anything. On
// the first authorization after launch the store is often still empty,
// and reading it then reported "no redirect URI" for a server that had
// one — so the config was rewritten needlessly and OpenCode reloaded in
// front of the user for no reason.
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
// `oauth: false` means the user disabled it explicitly.
const currentOAuth = existing && 'oauth' in existing && existing.oauth
? existing.oauth
: null;
// Rewritten when it does not match the callback we would receive right
// now — not merely when it is missing.
//
// The desktop app's loopback port changes between launches, so a stored
// redirect from an earlier session points at a port nothing serves any
// more: the provider redirects into the void and authorization never
// completes. Comparing instead of checking for absence also means the
// config is left alone — and OpenCode is not reloaded — whenever the
// stored value is already right, which is every run after the first.
const desiredRedirectUri = buildMcpAuthorizationRedirectUri(name);
if (existing && currentOAuth?.redirectUri !== desiredRedirectUri) {
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth?.clientId ?? '',
oauthClientSecret: currentOAuth?.clientSecret ?? '',
oauthScope: currentOAuth?.scope ?? '',
oauthRedirectUri: desiredRedirectUri,
});
if (!saved.ok) {
throw new McpAuthorizationError(
saved.message || 'Failed to save the authorization callback URL',
);
}
}
}
const authorizationUrl = await useMcpStore.getState().startAuth(name, directory ?? null);
const state = parseMcpOAuthCallbackStateKey(new URL(authorizationUrl).searchParams);
if (state) {
queuedState = state;
await queuePendingContext({
state,
name,
directory,
origin: isDesktopShell() ? MCP_OAUTH_ORIGIN_DESKTOP : null,
});
}
const opened = await openExternalUrl(authorizationUrl);
// The desktop app raises itself once the server reports success, rather
// than waiting for 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 — so the return trip cannot start from there.
if (opened && isDesktopShell()) {
void waitForAuthorizationThenFocus(name, directory ?? null);
}
return { authorizationUrl, opened };
} catch (error) {
// A parked context whose flow never started would later resolve a stale
// server for an unrelated callback.
await clearPendingContext(queuedState);
throw error;
}
};
@@ -64,15 +64,21 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
)}
>
{hasHeader && (
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
<div className="min-w-0 space-y-1">
// Wraps rather than squeezes. The action cluster never shrinks, so on
// a narrow pane it used to starve the title until the name was a
// single letter and an ellipsis; giving the title block a basis lets
// the actions drop to their own line instead.
<div className="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-3 pb-6">
<div className="min-w-0 flex-1 basis-64 space-y-1">
{title != null ? (
isPlainTitle ? (
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{titleAccessory}
{/* A status badge carries a fixed word; compressing it
wraps the text inside its own pill. */}
<span className="shrink-0">{titleAccessory}</span>
</div>
) : (
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
@@ -89,7 +95,7 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
)
) : null}
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
{headerEnd}
{showSaveStatus && <SettingsSaveStatus />}
</div>
@@ -18,6 +18,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { buildLinkedIssue } from '@/lib/linkedIssues';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
@@ -395,7 +396,7 @@ export function GitHubIssuePickerDialog({
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const { sessionId } = await (async () => {
const { sessionId, sessionDirectory } = await (async () => {
if (createInWorktree) {
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
@@ -449,6 +450,23 @@ export function GitHubIssuePickerDialog({
const instructionsText = await renderMagicPrompt('github.issue.review.instructions');
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
// Record the thread this session was created for, so it stays visible as
// a context source once the opening message has scrolled away. A
// snapshot, never re-fetched; a failed write must not fail the flow.
void sessionActions.setLinkedIssue(
sessionId,
sessionDirectory,
buildLinkedIssue({
url: issue.url,
number: issue.number,
title: issue.title,
kind: 'issue',
author: issue.author,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
void useSessionUIStore.getState().sendMessage(
visiblePromptText,
providerID,
@@ -31,6 +31,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { buildLinkedIssue } from '@/lib/linkedIssues';
import { useConfigStore } from '@/stores/useConfigStore';
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
@@ -536,6 +537,22 @@ export function NewWorktreeDialog({
{ sessionId: args.sessionId },
);
// Record the thread this worktree session was created for, so it stays
// visible as a context source after the opening message scrolls away.
void sessionActions.setLinkedIssue(
args.sessionId,
args.directory,
buildLinkedIssue({
url: issueRes.issue.url,
number: issueRes.issue.number,
title: issueRes.issue.title,
kind: 'issue',
author: issueRes.issue.author,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
return;
}
@@ -576,6 +593,20 @@ export function NewWorktreeDialog({
{ sessionId: args.sessionId },
);
void sessionActions.setLinkedIssue(
args.sessionId,
args.directory,
buildLinkedIssue({
url: prContext.pr.url,
number: prContext.pr.number,
title: prContext.pr.title,
kind: 'pull',
author: prContext.pr.author,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
toast.success(t('session.newWorktree.toast.sessionFromPr'));
}
}, [
@@ -0,0 +1,87 @@
import React from 'react';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatQuotaResetLabel, formatQuotaValueLabel } from '@/lib/quota';
import { cn } from '@/lib/utils';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import type { UsageWindow } from '@/types';
import type { UsageProviderGroup } from './usageGroups';
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';
};
/**
* One elevated card per provider, each holding a run of quota windows.
*
* Built for narrow columns: labels truncate, values stay pinned right, and
* nothing relies on horizontal room the container may not have. Shared by the
* mobile session-metadata popover and the work-status panel.
*/
export const UsageProviderCards: React.FC<{
groups: UsageProviderGroup[];
displayMode: 'usage' | 'remaining';
timeFormatPreference: TimeFormatPreference;
className?: string;
}> = ({ groups, displayMode, timeFormatPreference, className }) => (
<div className={cn('space-y-1.5', className)}>
{groups.map((group) => (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">{group.status}</span>
) : null}
</div>
{group.rows.length > 0 ? (
<div className="mt-1.5 space-y-1">
{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 (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">
{resetLabel}
</span>
) : null}
</span>
<span
className={cn(
'shrink-0 typography-ui-label font-semibold tabular-nums',
getWindowValueClass(row.window),
)}
>
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
);
})}
</div>
) : null}
{group.status && group.rows.length > 0 ? (
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
) : null}
</div>
))}
</div>
);
@@ -0,0 +1,84 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { getDisplayModelName } from '@/lib/quota/model-families';
import { useQuotaStore } from '@/stores/useQuotaStore';
import type { QuotaProviderId, UsageWindow } from '@/types';
export type UsageLimitRow = {
key: string;
label: string;
subtitle?: string;
window: UsageWindow;
};
export type UsageProviderGroup = {
providerId: QuotaProviderId;
providerName: string;
rows: UsageLimitRow[];
/** Provider-level message: a fetch error, or "nothing reported". */
status: string | null;
};
/**
* Quota windows grouped by provider, shaped for the compact usage list.
*
* Shared by the mobile session-metadata popover and the work-status panel so
* the two cannot drift on which providers appear, how model rows are filtered,
* or what counts as a provider-level status.
*
* Only providers the user put in the dropdown *and* that reported themselves as
* configured are included an unconfigured provider has nothing to say, and
* listing it reads as a fault.
*/
export const useUsageProviderGroups = (): UsageProviderGroup[] => {
const { t } = useI18n();
const quotaResults = useQuotaStore((state) => state.results);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
return React.useMemo<UsageProviderGroup[]>(() => {
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: UsageLimitRow[] = [];
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]);
};
+14
View File
@@ -7,6 +7,8 @@ import type { TerminalShell } from '@/lib/api/types';
type AppearanceSlice = {
showReasoningTraces: boolean;
workStatusPanelEnabled: boolean;
workStatusHiddenSections: string[];
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
sessionGoalEnabled: boolean;
@@ -60,6 +62,8 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled,
sessionGoalEnabled: useUIStore.getState().sessionGoalEnabled,
@@ -100,6 +104,8 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
sessionGoalEnabled: state.sessionGoalEnabled,
@@ -139,6 +145,14 @@ export const startAppearanceAutoSave = (): void => {
const diff: Partial<DesktopSettings> = {};
if (current.workStatusPanelEnabled !== previous.workStatusPanelEnabled) {
diff.workStatusPanelEnabled = current.workStatusPanelEnabled;
}
// Compared by content: the store hands back a new array on every change,
// so an identity check would push a write on unrelated store updates.
if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')) {
diff.workStatusHiddenSections = current.workStatusHiddenSections;
}
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
+21
View File
@@ -68,6 +68,10 @@ export type DesktopSettings = {
securityScopedBookmarks?: string[];
pinnedDirectories?: string[];
showReasoningTraces?: boolean;
/** Whether the in-chat work-status panel may render. */
workStatusPanelEnabled?: boolean;
/** Work-status panel sections the user switched off. */
workStatusHiddenSections?: string[];
collapsibleThinkingBlocks?: boolean;
showDeletionDialog?: boolean;
nativeNotificationsEnabled?: boolean;
@@ -531,6 +535,23 @@ export const isDesktopShell = (): boolean => {
return isElectronShell();
};
/**
* Raises the desktop window.
*
* Used when work finishes somewhere the app cannot be reached from an MCP
* authorization completing in the system browser, for instance. Browsers will
* not follow a custom-protocol link back without a user gesture, so the app
* brings itself forward instead of asking the page to do it.
*/
export const focusDesktopWindow = async (): Promise<boolean> => {
if (!isDesktopShell()) return false;
try {
return Boolean(await invokeDesktop('desktop_focus_window'));
} catch {
return false;
}
};
export const canRequestNativeDirectoryAccess = (): boolean => (
isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive()
);
+97
View File
@@ -0,0 +1,97 @@
import {
getDesktopHostApiUrl,
locationMatchesHost,
normalizeHostUrl,
redactSensitiveUrl,
type DesktopHost,
} from '@/lib/desktopHosts';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
/**
* Which configured instance the window is actually talking to.
*
* This lives outside the host switcher because the header names the same
* instance on its button. When the header carried its own copy of the matching
* rules it was missing the relay branch, so a relay instance whose API base
* is the window origin and therefore matches no host URL fell through to the
* word "Instance" while the switcher two clicks away named it correctly.
*/
export const LOCAL_HOST_ID = 'local';
export const buildLocalDesktopHost = (localOrigin?: string | null): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
url: localOrigin || getLocalDesktopOrigin(),
});
export const getLocalDesktopOrigin = (): string => {
if (typeof window === 'undefined') return '';
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
};
export const runtimeKeyForDesktopHost = (host: DesktopHost): string => {
if (host.id === LOCAL_HOST_ID) return 'local';
return `host:${host.id}`;
};
type ResolvedDesktopHost = {
id: string;
label: string;
url: string;
};
export const resolveCurrentDesktopHost = (hosts: DesktopHost[]): ResolvedDesktopHost => {
const currentHref = typeof window === 'undefined' ? '' : window.location.href;
const localOrigin = hosts.find((host) => host.id === LOCAL_HOST_ID)?.url || getLocalDesktopOrigin();
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((host) => host.relay && runtimeKeyForDesktopHost(host) === 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((host) => (
runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : 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((host) => (currentHref ? locationMatchesHost(currentHref, host.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 };
}
// Nothing configured matches. Naming the address is still more use than the
// bare word "Instance"; the redaction strips anything credential-shaped.
return {
id: 'custom',
label: redactSensitiveUrl(normalizedCurrent || 'Instance'),
url: normalizedCurrent,
};
};
+89 -3
View File
@@ -1392,8 +1392,6 @@ export const dict = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': 'Instanz öffnen, Nutzung und MCP (aktuell: {current})',
'header.services.open': 'Dienste öffnen, Nutzung und MCP',
'header.services.tooltip.currentInstanceWithShortcuts': 'Aktuelle Instanz: {current} ({toggle}; nächster Tab {nextTab})',
'header.services.tooltip.servicesWithShortcuts': 'Dienste ({toggle}; nächster Tab {nextTab})',
'header.services.title': 'Dienste',
'header.services.viewAria': 'Dienste anzeigen',
'header.services.closeAria': 'Dienste schließen',
@@ -1410,7 +1408,6 @@ export const dict = {
'header.services.used': 'Verwendet',
'header.services.remaining': 'Verbleibend',
'header.services.modelFamily.other': 'Andere',
'header.services.shutdownDev': 'OpenChamber stoppen',
'header.actions.openPlanAria': 'Plan öffnen',
'header.actions.toggleChangesPanel': 'Änderungspanel',
'header.actions.toggleChangesPanelAria': 'Änderungspanel umschalten',
@@ -2940,4 +2937,93 @@ export const dict = {
'onboarding.localSetup.actions.checkAndContinue': 'Installation abgeschlossen, prüfen und fortfahren',
'onboarding.localSetup.status.autoContinue': 'Wir fahren automatisch fort, sobald die Installation erkannt wurde.',
'updateDialog.changelog.title': 'Neuigkeiten',
'chat.workStatus.ariaLabel': 'Arbeitsstatus',
'chat.workStatus.context.label': 'Kontext',
'chat.workStatus.git.changedFileSingle': '{count} Datei geändert',
'chat.workStatus.git.changedFilePlural': '{count} Dateien geändert',
'chat.workStatus.pr.untitled': 'Pull Request ohne Titel',
'chat.workStatus.pr.draft': 'Entwurf',
'chat.workStatus.pr.checks': 'Prüfungen',
'chat.workStatus.pr.checksFailed': '{count} fehlgeschlagen',
'chat.workStatus.pr.checksPending': '{count} laufen',
'chat.workStatus.pr.checksPassed': '{count} bestanden',
'chat.workStatus.attention.merge': 'Merge läuft',
'chat.workStatus.attention.rebase': 'Rebase läuft',
'chat.workStatus.attention.cherryPick': 'Cherry-Pick läuft',
'chat.workStatus.attention.revert': 'Revert läuft',
'chat.workStatus.attention.bisect': 'Bisect läuft',
'chat.workStatus.subagent.done': 'Fertig',
'chat.workStatus.subagent.untitled': 'Subagent',
'chat.workStatus.mcp.toggle': '{name} umschalten',
'chat.workStatus.mcp.needsAuth': 'Anmelden',
'chat.workStatus.mcp.failed': 'Fehlgeschlagen',
'chat.workStatus.pinned.unavailable': 'Angeheftete Nachricht',
'chat.workStatus.section.session': 'Sitzung',
'chat.workStatus.section.repository': 'Repository',
'chat.workStatus.section.subagents': 'Subagenten',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Angeheftete Nachrichten',
'chat.workStatus.section.tasks': 'Aufgaben',
'chat.workStatus.subagent.working': 'arbeitet',
'chat.workStatus.subagent.needsPermission': 'braucht Berechtigung',
'chat.workStatus.subagent.askedQuestion': 'hat gefragt',
'chat.workStatus.section.contextBreakdown': 'Kontextquellen',
'chat.workStatus.breakdown.skills': 'Skills',
'chat.workStatus.breakdown.mcp': 'MCP-Server',
'chat.workStatus.action.openChanges': 'Änderungen öffnen',
'chat.workStatus.action.openGit': 'Git-Panel öffnen',
'chat.workStatus.action.openPr': 'Pull Request öffnen',
'chat.workStatus.action.openSubagent': '{name} öffnen',
'chat.workStatus.section.usage': 'Nutzung',
'chat.workStatus.goal.open': 'Ziel verwalten',
'chat.workStatus.goal.pause': 'Pausieren',
'chat.workStatus.goal.resume': 'Fortsetzen',
'chat.workStatus.goal.updateFailed': 'Ziel konnte nicht aktualisiert werden',
'chat.workStatus.pinned.unpin': 'Nachricht lösen',
'chat.workStatus.pinned.reveal': 'Zur Nachricht',
'chat.workStatus.pinned.unpinFailed': 'Nachricht konnte nicht gelöst werden',
'chat.workStatus.action.openContext': 'Kontextpanel öffnen',
'chat.workStatus.section.linkedIssues': 'Verknüpft',
'chat.workStatus.linkedIssues.open': '#{number} auf GitHub öffnen',
'chat.workStatus.linkedIssues.unlink': 'Verknüpfung entfernen',
'chat.workStatus.linkedIssues.unlinkFailed': 'Verknüpfung konnte nicht entfernt werden',
'chat.workStatus.linkedIssues.link': 'Mit Sitzung verknüpfen',
'chat.workStatus.linkedIssues.linkFailed': 'Verknüpfen fehlgeschlagen',
'chat.workStatus.breakdown.issueCountSingle': '{count} Issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} Issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PRs',
'chat.workStatus.breakdown.skillCountSingle': '{count} Skill',
'chat.workStatus.breakdown.skillCountPlural': '{count} Skills',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Abschnitte wählen',
'chat.workStatus.sections.dialogTitle': 'Panel-Abschnitte',
'chat.workStatus.sections.dialogDescription': 'Wähle, was das Arbeitsstatus-Panel zeigt. Ausgeblendete Abschnitte behalten ihre Daten und werden nur nicht angezeigt.',
'header.workStatusPanel.toggleAria': 'Arbeitsstatus-Panel umschalten',
'header.workStatusPanel.hide': 'Arbeitsstatus ausblenden',
'header.workStatusPanel.show': 'Arbeitsstatus anzeigen',
'chat.workStatus.mcp.authorizeOpenFailed': 'Autorisierungsseite konnte nicht geöffnet werden',
'chat.workStatus.mcp.authorizeFailed': 'Autorisierung fehlgeschlagen',
'mcpDropdown.toast.authorizeOpenFailed': 'Autorisierungsseite konnte nicht geöffnet werden',
'mcpDropdown.toast.authorizeFailed': 'Autorisierung fehlgeschlagen',
'header.services.tooltip.currentInstance': 'Aktuelle Instanz: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Arbeitsstatus über dem Chat anzeigen',
'settings.mcp.page.connection.title': 'Wie erreichbar',
'settings.mcp.page.connection.description': 'Füge den Startbefehl ein oder den Link zu einem gehosteten Server.',
'settings.mcp.page.registration.title': 'Dieser Server braucht eine eigene App',
'settings.mcp.page.registration.description': 'Er vergibt Zugangsdaten nicht automatisch. Lege in den Einstellungen des Dienstes eine App an, füge ihre Daten hier ein und melde dich an.',
'settings.mcp.page.registration.callbackLabel': 'Gib dem Dienst diese Adresse',
'settings.mcp.page.registration.clientId': 'App-ID',
'settings.mcp.page.registration.clientSecret': 'App-Secret',
'settings.mcp.page.registration.afterSaving': 'Speichern, dann auf Autorisieren drücken.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Adresse kopiert',
'settings.mcp.page.toast.clipboardWriteFailed': 'Kopieren in die Zwischenablage fehlgeschlagen',
'settings.mcp.page.scope.everywhere': 'In allen Projekten verfügbar',
'settings.mcp.page.scope.thisProject': 'Nur in diesem Projekt',
'settings.mcp.page.env.description': 'Werte, die der Server braucht, etwa einen API-Schlüssel.',
'settings.mcp.page.connection.kindCommand': 'Befehl',
'settings.mcp.page.connection.kindLink': 'Link',
'settings.mcp.page.connection.hintCommand': 'Läuft auf diesem Rechner. Fügen Sie einen ganzen Befehl ein — er wird in ein Argument pro Zeile zerlegt.',
'settings.mcp.page.connection.hintLink': 'Verbindet sich mit einem fremd gehosteten Server. Fügen Sie dessen https-Adresse ein.',
};
+89 -3
View File
@@ -1545,8 +1545,6 @@ export const dict = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': 'Open instance, usage and MCP (current: {current})',
'header.services.open': 'Open services, usage and MCP',
'header.services.tooltip.currentInstanceWithShortcuts': 'Current instance: {current} ({toggle}; next tab {nextTab})',
'header.services.tooltip.servicesWithShortcuts': 'Services ({toggle}; next tab {nextTab})',
'header.services.title': 'Services',
'header.services.viewAria': 'View services',
'header.services.closeAria': 'Close services',
@@ -1563,7 +1561,6 @@ export const dict = {
'header.services.used': 'Used',
'header.services.remaining': 'Remaining',
'header.services.modelFamily.other': 'Other',
'header.services.shutdownDev': 'Stop OpenChamber',
'header.actions.openPlanAria': 'Open plan',
'header.actions.toggleChangesPanel': 'Changes panel',
'header.actions.toggleChangesPanelAria': 'Toggle changes panel',
@@ -2942,6 +2939,95 @@ export const dict = {
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': 'Work status',
'chat.workStatus.context.label': 'Context',
'chat.workStatus.git.changedFileSingle': '{count} file changed',
'chat.workStatus.git.changedFilePlural': '{count} files changed',
'chat.workStatus.pr.untitled': 'Untitled pull request',
'chat.workStatus.pr.draft': 'Draft',
'chat.workStatus.pr.checks': 'Checks',
'chat.workStatus.pr.checksFailed': '{count} failed',
'chat.workStatus.pr.checksPending': '{count} running',
'chat.workStatus.pr.checksPassed': '{count} passed',
'chat.workStatus.attention.merge': 'Merge in progress',
'chat.workStatus.attention.rebase': 'Rebase in progress',
'chat.workStatus.attention.cherryPick': 'Cherry-pick in progress',
'chat.workStatus.attention.revert': 'Revert in progress',
'chat.workStatus.attention.bisect': 'Bisect in progress',
'chat.workStatus.subagent.done': 'Done',
'chat.workStatus.subagent.untitled': 'Subagent',
'chat.workStatus.mcp.toggle': 'Toggle {name}',
'chat.workStatus.mcp.needsAuth': 'Sign in',
'chat.workStatus.mcp.failed': 'Failed',
'chat.workStatus.pinned.unavailable': 'Pinned message',
'chat.workStatus.section.session': 'Session',
'chat.workStatus.section.repository': 'Repository',
'chat.workStatus.section.subagents': 'Subagents',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Pinned messages',
'chat.workStatus.section.tasks': 'Tasks',
'chat.workStatus.subagent.working': 'is working',
'chat.workStatus.subagent.needsPermission': 'needs permission',
'chat.workStatus.subagent.askedQuestion': 'asked a question',
'chat.workStatus.section.contextBreakdown': 'Context sources',
'chat.workStatus.breakdown.skills': 'Skills',
'chat.workStatus.breakdown.mcp': 'MCP servers',
'chat.workStatus.action.openChanges': 'Open changes',
'chat.workStatus.action.openGit': 'Open Git panel',
'chat.workStatus.action.openPr': 'Open pull request',
'chat.workStatus.action.openSubagent': 'Open {name}',
'chat.workStatus.section.usage': 'Usage',
'chat.workStatus.goal.open': 'Manage goal',
'chat.workStatus.goal.pause': 'Pause',
'chat.workStatus.goal.resume': 'Resume',
'chat.workStatus.goal.updateFailed': 'Could not update the goal',
'chat.workStatus.pinned.unpin': 'Unpin message',
'chat.workStatus.pinned.reveal': 'Go to message',
'chat.workStatus.pinned.unpinFailed': 'Could not unpin the message',
'chat.workStatus.action.openContext': 'Open context panel',
'chat.workStatus.section.linkedIssues': 'Linked',
'chat.workStatus.linkedIssues.open': 'Open #{number} on GitHub',
'chat.workStatus.linkedIssues.unlink': 'Remove link',
'chat.workStatus.linkedIssues.unlinkFailed': 'Could not remove the link',
'chat.workStatus.linkedIssues.link': 'Link to session',
'chat.workStatus.linkedIssues.linkFailed': 'Could not link',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PRs',
'chat.workStatus.breakdown.skillCountSingle': '{count} skill',
'chat.workStatus.breakdown.skillCountPlural': '{count} skills',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Choose sections',
'chat.workStatus.sections.dialogTitle': 'Panel sections',
'chat.workStatus.sections.dialogDescription': 'Choose what the work-status panel shows. Hidden sections keep their data — they are only left out of the panel.',
'header.workStatusPanel.toggleAria': 'Toggle work-status panel',
'header.workStatusPanel.hide': 'Hide work status',
'header.workStatusPanel.show': 'Show work status',
'chat.workStatus.mcp.authorizeOpenFailed': 'Could not open the authorization page',
'chat.workStatus.mcp.authorizeFailed': 'Authorization failed',
'mcpDropdown.toast.authorizeOpenFailed': 'Could not open the authorization page',
'mcpDropdown.toast.authorizeFailed': 'Authorization failed',
'header.services.tooltip.currentInstance': 'Current instance: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Show work status over the chat',
'settings.mcp.page.connection.title': 'How to reach it',
'settings.mcp.page.connection.description': 'Paste the command that starts it, or the link to a hosted server.',
'settings.mcp.page.registration.title': 'This server needs its own app',
'settings.mcp.page.registration.description': 'It does not hand out credentials automatically. Create an app in the services own settings, paste its details here, then sign in.',
'settings.mcp.page.registration.callbackLabel': 'Give the service this address',
'settings.mcp.page.registration.clientId': 'App ID',
'settings.mcp.page.registration.clientSecret': 'App secret',
'settings.mcp.page.registration.afterSaving': 'Save, then press Authorize to sign in.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Address copied',
'settings.mcp.page.toast.clipboardWriteFailed': 'Could not copy to the clipboard',
'settings.mcp.page.scope.everywhere': 'Available in every project',
'settings.mcp.page.scope.thisProject': 'Only in this project',
'settings.mcp.page.env.description': 'Values the server needs, such as an API key.',
'settings.mcp.page.connection.kindCommand': 'Command',
'settings.mcp.page.connection.kindLink': 'Link',
'settings.mcp.page.connection.hintCommand': 'Runs on this machine. Paste a whole command and it is split into one argument per line.',
'settings.mcp.page.connection.hintLink': 'Connects to a server someone else hosts. Paste its https address.',
} as const;
export type I18nKey = keyof typeof dict;
+89 -3
View File
@@ -1523,8 +1523,6 @@ export const dict: Record<I18nKey, string> = {
"header.github.accountSource.cli": "CLI",
"header.services.openWithCurrent": "Abrir instancia, uso y MCP (actual: {current})",
"header.services.open": "Abrir servicios, uso y MCP",
"header.services.tooltip.currentInstanceWithShortcuts": "Instancia actual: {current} ({toggle}; siguiente pestaña {nextTab})",
"header.services.tooltip.servicesWithShortcuts": "Servicios ({toggle}; siguiente pestaña {nextTab})",
"header.services.title": "Servicios",
"header.services.viewAria": "Ver servicios",
"header.services.closeAria": "Cerrar servicios",
@@ -1541,7 +1539,6 @@ export const dict: Record<I18nKey, string> = {
"header.services.used": "Usado",
"header.services.remaining": "Restante",
"header.services.modelFamily.other": "Otro",
"header.services.shutdownDev": "Detener OpenChamber",
"header.actions.openPlanAria": "Abrir plan",
"header.actions.toggleChangesPanel": "Panel de cambios",
"header.actions.toggleChangesPanelAria": "Alternar panel de cambios",
@@ -2943,4 +2940,93 @@ export const dict: Record<I18nKey, string> = {
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Estado del trabajo',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.git.changedFileSingle': '{count} archivo modificado',
'chat.workStatus.git.changedFilePlural': '{count} archivos modificados',
'chat.workStatus.pr.untitled': 'Pull request sin título',
'chat.workStatus.pr.draft': 'Borrador',
'chat.workStatus.pr.checks': 'Comprobaciones',
'chat.workStatus.pr.checksFailed': '{count} fallaron',
'chat.workStatus.pr.checksPending': '{count} en curso',
'chat.workStatus.pr.checksPassed': '{count} correctas',
'chat.workStatus.attention.merge': 'Fusión en curso',
'chat.workStatus.attention.rebase': 'Rebase en curso',
'chat.workStatus.attention.cherryPick': 'Cherry-pick en curso',
'chat.workStatus.attention.revert': 'Reversión en curso',
'chat.workStatus.attention.bisect': 'Bisect en curso',
'chat.workStatus.subagent.done': 'Listo',
'chat.workStatus.subagent.untitled': 'Subagente',
'chat.workStatus.mcp.toggle': 'Alternar {name}',
'chat.workStatus.mcp.needsAuth': 'Iniciar sesión',
'chat.workStatus.mcp.failed': 'Falló',
'chat.workStatus.pinned.unavailable': 'Mensaje fijado',
'chat.workStatus.section.session': 'Sesión',
'chat.workStatus.section.repository': 'Repositorio',
'chat.workStatus.section.subagents': 'Subagentes',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Mensajes fijados',
'chat.workStatus.section.tasks': 'Tareas',
'chat.workStatus.subagent.working': 'está trabajando',
'chat.workStatus.subagent.needsPermission': 'necesita permiso',
'chat.workStatus.subagent.askedQuestion': 'hizo una pregunta',
'chat.workStatus.section.contextBreakdown': 'Fuentes de contexto',
'chat.workStatus.breakdown.skills': 'Habilidades',
'chat.workStatus.breakdown.mcp': 'Servidores MCP',
'chat.workStatus.action.openChanges': 'Abrir cambios',
'chat.workStatus.action.openGit': 'Abrir panel de Git',
'chat.workStatus.action.openPr': 'Abrir pull request',
'chat.workStatus.action.openSubagent': 'Abrir {name}',
'chat.workStatus.section.usage': 'Uso',
'chat.workStatus.goal.open': 'Gestionar objetivo',
'chat.workStatus.goal.pause': 'Pausar',
'chat.workStatus.goal.resume': 'Reanudar',
'chat.workStatus.goal.updateFailed': 'No se pudo actualizar el objetivo',
'chat.workStatus.pinned.unpin': 'Dejar de fijar',
'chat.workStatus.pinned.reveal': 'Ir al mensaje',
'chat.workStatus.pinned.unpinFailed': 'No se pudo dejar de fijar el mensaje',
'chat.workStatus.action.openContext': 'Abrir panel de contexto',
'chat.workStatus.section.linkedIssues': 'Vinculados',
'chat.workStatus.linkedIssues.open': 'Abrir #{number} en GitHub',
'chat.workStatus.linkedIssues.unlink': 'Quitar vínculo',
'chat.workStatus.linkedIssues.unlinkFailed': 'No se pudo quitar el vínculo',
'chat.workStatus.linkedIssues.link': 'Vincular a la sesión',
'chat.workStatus.linkedIssues.linkFailed': 'No se pudo vincular',
'chat.workStatus.breakdown.issueCountSingle': '{count} incidencia',
'chat.workStatus.breakdown.issueCountPlural': '{count} incidencias',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PR',
'chat.workStatus.breakdown.skillCountSingle': '{count} habilidad',
'chat.workStatus.breakdown.skillCountPlural': '{count} habilidades',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Elegir secciones',
'chat.workStatus.sections.dialogTitle': 'Secciones del panel',
'chat.workStatus.sections.dialogDescription': 'Elige qué muestra el panel de estado. Las secciones ocultas conservan sus datos: solo no aparecen en el panel.',
'header.workStatusPanel.toggleAria': 'Alternar panel de estado',
'header.workStatusPanel.hide': 'Ocultar estado del trabajo',
'header.workStatusPanel.show': 'Mostrar estado del trabajo',
'chat.workStatus.mcp.authorizeOpenFailed': 'No se pudo abrir la página de autorización',
'chat.workStatus.mcp.authorizeFailed': 'Error de autorización',
'mcpDropdown.toast.authorizeOpenFailed': 'No se pudo abrir la página de autorización',
'mcpDropdown.toast.authorizeFailed': 'Error de autorización',
'header.services.tooltip.currentInstance': 'Instancia actual: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Mostrar estado sobre el chat',
'settings.mcp.page.connection.title': 'Cómo conectarse',
'settings.mcp.page.connection.description': 'Pega el comando que lo inicia o el enlace a un servidor alojado.',
'settings.mcp.page.registration.title': 'Este servidor necesita su propia app',
'settings.mcp.page.registration.description': 'No entrega credenciales automáticamente. Crea una app en los ajustes del servicio, pega sus datos aquí e inicia sesión.',
'settings.mcp.page.registration.callbackLabel': 'Da esta dirección al servicio',
'settings.mcp.page.registration.clientId': 'ID de la app',
'settings.mcp.page.registration.clientSecret': 'Secreto de la app',
'settings.mcp.page.registration.afterSaving': 'Guarda y luego pulsa Autorizar.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Dirección copiada',
'settings.mcp.page.toast.clipboardWriteFailed': 'No se pudo copiar al portapapeles',
'settings.mcp.page.scope.everywhere': 'Disponible en todos los proyectos',
'settings.mcp.page.scope.thisProject': 'Solo en este proyecto',
'settings.mcp.page.env.description': 'Valores que el servidor necesita, como una clave de API.',
'settings.mcp.page.connection.kindCommand': 'Comando',
'settings.mcp.page.connection.kindLink': 'Enlace',
'settings.mcp.page.connection.hintCommand': 'Se ejecuta en este equipo. Pega un comando completo y se dividirá en un argumento por línea.',
'settings.mcp.page.connection.hintLink': 'Se conecta a un servidor alojado por otra persona. Pega su dirección https.',
};
+89 -3
View File
@@ -1364,8 +1364,6 @@ export const dict = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': 'Instance ouverte, utilisation et MCP (actuel : {current})',
'header.services.open': 'Services ouverts, utilisation et MCP',
'header.services.tooltip.currentInstanceWithShortcuts': 'Instance actuelle : {current} ({toggle} ; onglet suivant {nextTab})',
'header.services.tooltip.servicesWithShortcuts': 'Services ({toggle} ; onglet suivant {nextTab})',
'header.services.title': 'Services',
'header.services.viewAria': 'Voir les prestations',
'header.services.closeAria': 'Fermer les prestations',
@@ -1376,7 +1374,6 @@ export const dict = {
'header.services.used': 'Utilisé',
'header.services.remaining': 'Restant',
'header.services.modelFamily.other': 'Autre',
'header.services.shutdownDev': 'Arrêter OpenChamber',
'header.actions.openPlanAria': 'Plan ouvert',
"header.actions.toggleChangesPanel": "Panneau des changements",
"header.actions.toggleChangesPanelAria": "Basculer le panneau des changements",
@@ -2940,6 +2937,95 @@ export const dict = {
'vscodeLayout.actions.archiveAllSuccess': '{count} session(s) archivée(s)',
'vscodeLayout.actions.archiveAllError': 'Impossible darchiver {count} session(s)',
'vscodeLayout.actions.cancel': 'Annuler',
'chat.workStatus.ariaLabel': 'État du travail',
'chat.workStatus.context.label': 'Contexte',
'chat.workStatus.git.changedFileSingle': '{count} fichier modifié',
'chat.workStatus.git.changedFilePlural': '{count} fichiers modifiés',
'chat.workStatus.pr.untitled': 'Pull request sans titre',
'chat.workStatus.pr.draft': 'Brouillon',
'chat.workStatus.pr.checks': 'Vérifications',
'chat.workStatus.pr.checksFailed': '{count} en échec',
'chat.workStatus.pr.checksPending': '{count} en cours',
'chat.workStatus.pr.checksPassed': '{count} réussies',
'chat.workStatus.attention.merge': 'Fusion en cours',
'chat.workStatus.attention.rebase': 'Rebase en cours',
'chat.workStatus.attention.cherryPick': 'Cherry-pick en cours',
'chat.workStatus.attention.revert': 'Revert en cours',
'chat.workStatus.attention.bisect': 'Bisect en cours',
'chat.workStatus.subagent.done': 'Terminé',
'chat.workStatus.subagent.untitled': 'Sous-agent',
'chat.workStatus.mcp.toggle': 'Basculer {name}',
'chat.workStatus.mcp.needsAuth': 'Se connecter',
'chat.workStatus.mcp.failed': 'Échec',
'chat.workStatus.pinned.unavailable': 'Message épinglé',
'chat.workStatus.section.session': 'Session',
'chat.workStatus.section.repository': 'Dépôt',
'chat.workStatus.section.subagents': 'Sous-agents',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Messages épinglés',
'chat.workStatus.section.tasks': 'Tâches',
'chat.workStatus.subagent.working': 'travaille',
'chat.workStatus.subagent.needsPermission': 'demande une autorisation',
'chat.workStatus.subagent.askedQuestion': 'a posé une question',
'chat.workStatus.section.contextBreakdown': 'Sources de contexte',
'chat.workStatus.breakdown.skills': 'Compétences',
'chat.workStatus.breakdown.mcp': 'Serveurs MCP',
'chat.workStatus.action.openChanges': 'Ouvrir les modifications',
'chat.workStatus.action.openGit': 'Ouvrir le panneau Git',
'chat.workStatus.action.openPr': 'Ouvrir la pull request',
'chat.workStatus.action.openSubagent': 'Ouvrir {name}',
'chat.workStatus.section.usage': 'Utilisation',
'chat.workStatus.goal.open': 'Gérer lobjectif',
'chat.workStatus.goal.pause': 'Mettre en pause',
'chat.workStatus.goal.resume': 'Reprendre',
'chat.workStatus.goal.updateFailed': 'Impossible de mettre à jour lobjectif',
'chat.workStatus.pinned.unpin': 'Détacher le message',
'chat.workStatus.pinned.reveal': 'Aller au message',
'chat.workStatus.pinned.unpinFailed': 'Impossible de détacher le message',
'chat.workStatus.action.openContext': 'Ouvrir le panneau de contexte',
'chat.workStatus.section.linkedIssues': 'Liés',
'chat.workStatus.linkedIssues.open': 'Ouvrir #{number} sur GitHub',
'chat.workStatus.linkedIssues.unlink': 'Retirer le lien',
'chat.workStatus.linkedIssues.unlinkFailed': 'Impossible de retirer le lien',
'chat.workStatus.linkedIssues.link': 'Lier à la session',
'chat.workStatus.linkedIssues.linkFailed': 'Impossible de lier',
'chat.workStatus.breakdown.issueCountSingle': '{count} ticket',
'chat.workStatus.breakdown.issueCountPlural': '{count} tickets',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PR',
'chat.workStatus.breakdown.skillCountSingle': '{count} compétence',
'chat.workStatus.breakdown.skillCountPlural': '{count} compétences',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Choisir les sections',
'chat.workStatus.sections.dialogTitle': 'Sections du panneau',
'chat.workStatus.sections.dialogDescription': 'Choisis ce qu\'affiche le panneau d\'état. Les sections masquées conservent leurs données, elles sont seulement absentes du panneau.',
'header.workStatusPanel.toggleAria': 'Basculer le panneau d\'état',
'header.workStatusPanel.hide': 'Masquer l\'état du travail',
'header.workStatusPanel.show': 'Afficher l\'état du travail',
'chat.workStatus.mcp.authorizeOpenFailed': 'Impossible d\'ouvrir la page d\'autorisation',
'chat.workStatus.mcp.authorizeFailed': 'Échec de l\'autorisation',
'mcpDropdown.toast.authorizeOpenFailed': 'Impossible d\'ouvrir la page d\'autorisation',
'mcpDropdown.toast.authorizeFailed': 'Échec de l\'autorisation',
'header.services.tooltip.currentInstance': 'Instance actuelle : {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Afficher l\'état par-dessus le chat',
'settings.mcp.page.connection.title': 'Comment le joindre',
'settings.mcp.page.connection.description': 'Colle la commande qui le démarre, ou le lien vers un serveur hébergé.',
'settings.mcp.page.registration.title': 'Ce serveur exige sa propre app',
'settings.mcp.page.registration.description': 'Il ne fournit pas didentifiants automatiquement. Crée une app dans les réglages du service, colle ses informations ici, puis connecte-toi.',
'settings.mcp.page.registration.callbackLabel': 'Donne cette adresse au service',
'settings.mcp.page.registration.clientId': 'ID de lapp',
'settings.mcp.page.registration.clientSecret': 'Secret de lapp',
'settings.mcp.page.registration.afterSaving': 'Enregistre, puis appuie sur Autoriser.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Adresse copiée',
'settings.mcp.page.toast.clipboardWriteFailed': 'Impossible de copier dans le presse-papiers',
'settings.mcp.page.scope.everywhere': 'Disponible dans tous les projets',
'settings.mcp.page.scope.thisProject': 'Seulement dans ce projet',
'settings.mcp.page.env.description': 'Valeurs dont le serveur a besoin, par exemple une clé dAPI.',
'settings.mcp.page.connection.kindCommand': 'Commande',
'settings.mcp.page.connection.kindLink': 'Lien',
'settings.mcp.page.connection.hintCommand': 'Sexécute sur cette machine. Collez une commande entière : elle est découpée en un argument par ligne.',
'settings.mcp.page.connection.hintLink': 'Se connecte à un serveur hébergé par quelquun dautre. Collez son adresse https.',
} as const;
export type I18nKey = keyof typeof dict;
+89 -3
View File
@@ -1541,8 +1541,6 @@ export const dict: Record<I18nKey, string> = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': 'インスタンス、使用量、MCPを開く(現在: {current}',
'header.services.open': 'サービス、使用量、MCPを開く',
'header.services.tooltip.currentInstanceWithShortcuts': '現在のインスタンス: {current}{toggle}、次のタブ {nextTab}',
'header.services.tooltip.servicesWithShortcuts': 'サービス({toggle}、次のタブ {nextTab}',
'header.services.title': 'サービス',
'header.services.viewAria': 'サービスを表示',
'header.services.closeAria': 'サービスを閉じる',
@@ -1559,7 +1557,6 @@ export const dict: Record<I18nKey, string> = {
'header.services.used': '使用済み',
'header.services.remaining': '残り',
'header.services.modelFamily.other': 'その他',
'header.services.shutdownDev': 'OpenChamberを停止',
'header.actions.openPlanAria': '計画を開く',
'header.actions.toggleChangesPanel': '変更パネル',
'header.actions.toggleChangesPanelAria': '変更パネルの切り替え',
@@ -2942,4 +2939,93 @@ export const dict: Record<I18nKey, string> = {
'updateDialog.changelog.title': '新機能',
'quota.window.premiumInteractions': 'プレミアムインタラクション',
'chat.workStatus.ariaLabel': '作業状況',
'chat.workStatus.context.label': 'コンテキスト',
'chat.workStatus.git.changedFileSingle': '{count} 件のファイルを変更',
'chat.workStatus.git.changedFilePlural': '{count} 件のファイルを変更',
'chat.workStatus.pr.untitled': 'タイトルなしのプルリクエスト',
'chat.workStatus.pr.draft': 'ドラフト',
'chat.workStatus.pr.checks': 'チェック',
'chat.workStatus.pr.checksFailed': '{count} 件失敗',
'chat.workStatus.pr.checksPending': '{count} 件実行中',
'chat.workStatus.pr.checksPassed': '{count} 件成功',
'chat.workStatus.attention.merge': 'マージ実行中',
'chat.workStatus.attention.rebase': 'リベース実行中',
'chat.workStatus.attention.cherryPick': 'チェリーピック実行中',
'chat.workStatus.attention.revert': 'リバート実行中',
'chat.workStatus.attention.bisect': '二分探索実行中',
'chat.workStatus.subagent.done': '完了',
'chat.workStatus.subagent.untitled': 'サブエージェント',
'chat.workStatus.mcp.toggle': '{name} を切り替え',
'chat.workStatus.mcp.needsAuth': 'サインイン',
'chat.workStatus.mcp.failed': '失敗',
'chat.workStatus.pinned.unavailable': 'ピン留めしたメッセージ',
'chat.workStatus.section.session': 'セッション',
'chat.workStatus.section.repository': 'リポジトリ',
'chat.workStatus.section.subagents': 'サブエージェント',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'ピン留めしたメッセージ',
'chat.workStatus.section.tasks': 'タスク',
'chat.workStatus.subagent.working': '作業中',
'chat.workStatus.subagent.needsPermission': '許可が必要',
'chat.workStatus.subagent.askedQuestion': '質問があります',
'chat.workStatus.section.contextBreakdown': 'コンテキストソース',
'chat.workStatus.breakdown.skills': 'スキル',
'chat.workStatus.breakdown.mcp': 'MCP サーバー',
'chat.workStatus.action.openChanges': '変更を開く',
'chat.workStatus.action.openGit': 'Git パネルを開く',
'chat.workStatus.action.openPr': 'プルリクエストを開く',
'chat.workStatus.action.openSubagent': '{name} を開く',
'chat.workStatus.section.usage': '使用量',
'chat.workStatus.goal.open': '目標を管理',
'chat.workStatus.goal.pause': '一時停止',
'chat.workStatus.goal.resume': '再開',
'chat.workStatus.goal.updateFailed': '目標を更新できませんでした',
'chat.workStatus.pinned.unpin': 'ピン留めを解除',
'chat.workStatus.pinned.reveal': 'メッセージへ移動',
'chat.workStatus.pinned.unpinFailed': 'ピン留めを解除できませんでした',
'chat.workStatus.action.openContext': 'コンテキストパネルを開く',
'chat.workStatus.section.linkedIssues': 'リンク済み',
'chat.workStatus.linkedIssues.open': 'GitHub で #{number} を開く',
'chat.workStatus.linkedIssues.unlink': 'リンクを解除',
'chat.workStatus.linkedIssues.unlinkFailed': 'リンクを解除できませんでした',
'chat.workStatus.linkedIssues.link': 'セッションにリンク',
'chat.workStatus.linkedIssues.linkFailed': 'リンクできませんでした',
'chat.workStatus.breakdown.issueCountSingle': 'Issue {count} 件',
'chat.workStatus.breakdown.issueCountPlural': 'Issue {count} 件',
'chat.workStatus.breakdown.prCountSingle': 'PR {count} 件',
'chat.workStatus.breakdown.prCountPlural': 'PR {count} 件',
'chat.workStatus.breakdown.skillCountSingle': 'スキル {count} 個',
'chat.workStatus.breakdown.skillCountPlural': 'スキル {count} 個',
'chat.workStatus.breakdown.mcpCountSingle': 'MCP {count} 個',
'chat.workStatus.breakdown.mcpCountPlural': 'MCP {count} 個',
'chat.workStatus.sections.open': 'セクションを選択',
'chat.workStatus.sections.dialogTitle': 'パネルのセクション',
'chat.workStatus.sections.dialogDescription': '作業状況パネルに表示する内容を選びます。非表示のセクションもデータは保持され、表示されないだけです。',
'header.workStatusPanel.toggleAria': '作業状況パネルを切り替え',
'header.workStatusPanel.hide': '作業状況を非表示',
'header.workStatusPanel.show': '作業状況を表示',
'chat.workStatus.mcp.authorizeOpenFailed': '認証ページを開けませんでした',
'chat.workStatus.mcp.authorizeFailed': '認証に失敗しました',
'mcpDropdown.toast.authorizeOpenFailed': '認証ページを開けませんでした',
'mcpDropdown.toast.authorizeFailed': '認証に失敗しました',
'header.services.tooltip.currentInstance': '現在のインスタンス: {current}{toggle}',
'header.workStatusPanel.showOverlay': 'チャットの上に作業状況を表示',
'settings.mcp.page.connection.title': '接続方法',
'settings.mcp.page.connection.description': '起動コマンド、またはホスト型サーバーのリンクを貼り付けます。',
'settings.mcp.page.registration.title': 'このサーバーには専用アプリが必要です',
'settings.mcp.page.registration.description': '認証情報を自動発行しません。サービス側の設定でアプリを作成し、その情報をここに貼り付けてサインインしてください。',
'settings.mcp.page.registration.callbackLabel': 'この住所をサービスに登録してください',
'settings.mcp.page.registration.clientId': 'アプリ ID',
'settings.mcp.page.registration.clientSecret': 'アプリシークレット',
'settings.mcp.page.registration.afterSaving': '保存してから「認証」を押してください。',
'settings.mcp.page.toast.copiedCallbackUrl': '住所をコピーしました',
'settings.mcp.page.toast.clipboardWriteFailed': 'クリップボードにコピーできませんでした',
'settings.mcp.page.scope.everywhere': 'すべてのプロジェクトで利用可能',
'settings.mcp.page.scope.thisProject': 'このプロジェクトのみ',
'settings.mcp.page.env.description': 'API キーなど、サーバーが必要とする値。',
'settings.mcp.page.connection.kindCommand': 'コマンド',
'settings.mcp.page.connection.kindLink': 'リンク',
'settings.mcp.page.connection.hintCommand': 'このマシンで実行します。コマンド全体を貼り付けると、1 行に 1 引数へ分割されます。',
'settings.mcp.page.connection.hintLink': '他者がホストするサーバーに接続します。その https アドレスを貼り付けてください。',
};
+89 -3
View File
@@ -1240,7 +1240,6 @@ export const dict: Record<I18nKey, string> = {
'contextPanel.preview.console.filter.logs': '로그',
'terminalView.preview.open': '미리보기',
'terminalView.preview.openTitle': '미리보기 패널 열기',
'header.services.shutdownDev': 'OpenChamber 종료',
'chat.messageBody.actions.openPreviewAria': '미리보기 열기',
'chat.messageBody.actions.openPreview': '미리보기 열기',
'contextPanel.tab.closeTabAria': '{label} 탭 닫기',
@@ -1548,8 +1547,6 @@ export const dict: Record<I18nKey, string> = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': '인스턴스, 사용량, MCP 열기(현재: {current})',
'header.services.open': '서비스, 사용량, MCP 열기',
'header.services.tooltip.currentInstanceWithShortcuts': '현재 인스턴스: {current} ({toggle}; 다음 탭 {nextTab})',
'header.services.tooltip.servicesWithShortcuts': '서비스 ({toggle}; 다음 탭 {nextTab})',
'header.services.title': '서비스',
'header.services.viewAria': '서비스 보기',
'header.services.closeAria': '서비스 닫기',
@@ -2942,4 +2939,93 @@ export const dict: Record<I18nKey, string> = {
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '작업 상태',
'chat.workStatus.context.label': '컨텍스트',
'chat.workStatus.git.changedFileSingle': '파일 {count}개 변경됨',
'chat.workStatus.git.changedFilePlural': '파일 {count}개 변경됨',
'chat.workStatus.pr.untitled': '제목 없는 풀 리퀘스트',
'chat.workStatus.pr.draft': '초안',
'chat.workStatus.pr.checks': '검사',
'chat.workStatus.pr.checksFailed': '{count}개 실패',
'chat.workStatus.pr.checksPending': '{count}개 실행 중',
'chat.workStatus.pr.checksPassed': '{count}개 통과',
'chat.workStatus.attention.merge': '병합 진행 중',
'chat.workStatus.attention.rebase': '리베이스 진행 중',
'chat.workStatus.attention.cherryPick': '체리픽 진행 중',
'chat.workStatus.attention.revert': '되돌리기 진행 중',
'chat.workStatus.attention.bisect': '이분 탐색 진행 중',
'chat.workStatus.subagent.done': '완료',
'chat.workStatus.subagent.untitled': '서브에이전트',
'chat.workStatus.mcp.toggle': '{name} 전환',
'chat.workStatus.mcp.needsAuth': '로그인',
'chat.workStatus.mcp.failed': '실패',
'chat.workStatus.pinned.unavailable': '고정된 메시지',
'chat.workStatus.section.session': '세션',
'chat.workStatus.section.repository': '저장소',
'chat.workStatus.section.subagents': '서브에이전트',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': '고정된 메시지',
'chat.workStatus.section.tasks': '작업',
'chat.workStatus.subagent.working': '작업 중',
'chat.workStatus.subagent.needsPermission': '권한 필요',
'chat.workStatus.subagent.askedQuestion': '질문함',
'chat.workStatus.section.contextBreakdown': '컨텍스트 소스',
'chat.workStatus.breakdown.skills': '스킬',
'chat.workStatus.breakdown.mcp': 'MCP 서버',
'chat.workStatus.action.openChanges': '변경 사항 열기',
'chat.workStatus.action.openGit': 'Git 패널 열기',
'chat.workStatus.action.openPr': '풀 리퀘스트 열기',
'chat.workStatus.action.openSubagent': '{name} 열기',
'chat.workStatus.section.usage': '사용량',
'chat.workStatus.goal.open': '목표 관리',
'chat.workStatus.goal.pause': '일시정지',
'chat.workStatus.goal.resume': '재개',
'chat.workStatus.goal.updateFailed': '목표를 업데이트하지 못했습니다',
'chat.workStatus.pinned.unpin': '고정 해제',
'chat.workStatus.pinned.reveal': '메시지로 이동',
'chat.workStatus.pinned.unpinFailed': '고정을 해제하지 못했습니다',
'chat.workStatus.action.openContext': '컨텍스트 패널 열기',
'chat.workStatus.section.linkedIssues': '연결됨',
'chat.workStatus.linkedIssues.open': 'GitHub에서 #{number} 열기',
'chat.workStatus.linkedIssues.unlink': '연결 해제',
'chat.workStatus.linkedIssues.unlinkFailed': '연결을 해제하지 못했습니다',
'chat.workStatus.linkedIssues.link': '세션에 연결',
'chat.workStatus.linkedIssues.linkFailed': '연결하지 못했습니다',
'chat.workStatus.breakdown.issueCountSingle': '이슈 {count}개',
'chat.workStatus.breakdown.issueCountPlural': '이슈 {count}개',
'chat.workStatus.breakdown.prCountSingle': 'PR {count}개',
'chat.workStatus.breakdown.prCountPlural': 'PR {count}개',
'chat.workStatus.breakdown.skillCountSingle': '스킬 {count}개',
'chat.workStatus.breakdown.skillCountPlural': '스킬 {count}개',
'chat.workStatus.breakdown.mcpCountSingle': 'MCP {count}개',
'chat.workStatus.breakdown.mcpCountPlural': 'MCP {count}개',
'chat.workStatus.sections.open': '섹션 선택',
'chat.workStatus.sections.dialogTitle': '패널 섹션',
'chat.workStatus.sections.dialogDescription': '작업 상태 패널에 표시할 항목을 선택하세요. 숨긴 섹션도 데이터는 유지되며 패널에만 나타나지 않습니다.',
'header.workStatusPanel.toggleAria': '작업 상태 패널 전환',
'header.workStatusPanel.hide': '작업 상태 숨기기',
'header.workStatusPanel.show': '작업 상태 표시',
'chat.workStatus.mcp.authorizeOpenFailed': '인증 페이지를 열지 못했습니다',
'chat.workStatus.mcp.authorizeFailed': '인증에 실패했습니다',
'mcpDropdown.toast.authorizeOpenFailed': '인증 페이지를 열지 못했습니다',
'mcpDropdown.toast.authorizeFailed': '인증에 실패했습니다',
'header.services.tooltip.currentInstance': '현재 인스턴스: {current} ({toggle})',
'header.workStatusPanel.showOverlay': '채팅 위에 작업 상태 표시',
'settings.mcp.page.connection.title': '연결 방법',
'settings.mcp.page.connection.description': '실행 명령 또는 호스팅 서버 링크를 붙여넣으세요.',
'settings.mcp.page.registration.title': '이 서버는 전용 앱이 필요합니다',
'settings.mcp.page.registration.description': '자격 증명을 자동으로 발급하지 않습니다. 서비스 설정에서 앱을 만들고 정보를 여기에 붙여넣은 뒤 로그인하세요.',
'settings.mcp.page.registration.callbackLabel': '이 주소를 서비스에 등록하세요',
'settings.mcp.page.registration.clientId': '앱 ID',
'settings.mcp.page.registration.clientSecret': '앱 시크릿',
'settings.mcp.page.registration.afterSaving': '저장한 다음 인증을 누르세요.',
'settings.mcp.page.toast.copiedCallbackUrl': '주소를 복사했습니다',
'settings.mcp.page.toast.clipboardWriteFailed': '클립보드에 복사하지 못했습니다',
'settings.mcp.page.scope.everywhere': '모든 프로젝트에서 사용',
'settings.mcp.page.scope.thisProject': '이 프로젝트에서만',
'settings.mcp.page.env.description': 'API 키처럼 서버가 필요로 하는 값.',
'settings.mcp.page.connection.kindCommand': '명령',
'settings.mcp.page.connection.kindLink': '링크',
'settings.mcp.page.connection.hintCommand': '이 컴퓨터에서 실행됩니다. 명령 전체를 붙여넣으면 한 줄에 인수 하나씩 나뉩니다.',
'settings.mcp.page.connection.hintLink': '다른 곳에서 호스팅하는 서버에 연결합니다. https 주소를 붙여넣으세요.',
};
+89 -3
View File
@@ -2324,10 +2324,7 @@ export const dict: Record<I18nKey, string> = {
'header.services.rateLimits': 'Limity użycia',
'header.services.refreshRateLimitsAria': 'Odśwież limity użycia',
'header.services.remaining': 'Pozostało',
'header.services.shutdownDev': 'Zatrzymaj OpenChamber',
'header.services.title': 'Usługi',
'header.services.tooltip.currentInstanceWithShortcuts': 'Bieżąca instancja: {current} ({toggle}; następna karta {nextTab})',
'header.services.tooltip.servicesWithShortcuts': 'Usługi ({toggle}; następna karta {nextTab})',
'header.services.used': 'Wykorzystano',
'header.services.viewAria': 'Pokaż usługi',
'header.sessions.title': 'Sesje',
@@ -2959,4 +2956,93 @@ export const dict: Record<I18nKey, string> = {
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': 'Stan pracy',
'chat.workStatus.context.label': 'Kontekst',
'chat.workStatus.git.changedFileSingle': 'Zmieniono {count} plik',
'chat.workStatus.git.changedFilePlural': 'Zmieniono {count} plików',
'chat.workStatus.pr.untitled': 'Pull request bez tytułu',
'chat.workStatus.pr.draft': 'Szkic',
'chat.workStatus.pr.checks': 'Sprawdzenia',
'chat.workStatus.pr.checksFailed': '{count} nieudanych',
'chat.workStatus.pr.checksPending': '{count} w toku',
'chat.workStatus.pr.checksPassed': '{count} zaliczonych',
'chat.workStatus.attention.merge': 'Trwa scalanie',
'chat.workStatus.attention.rebase': 'Trwa rebase',
'chat.workStatus.attention.cherryPick': 'Trwa cherry-pick',
'chat.workStatus.attention.revert': 'Trwa cofanie',
'chat.workStatus.attention.bisect': 'Trwa bisect',
'chat.workStatus.subagent.done': 'Gotowe',
'chat.workStatus.subagent.untitled': 'Subagent',
'chat.workStatus.mcp.toggle': 'Przełącz {name}',
'chat.workStatus.mcp.needsAuth': 'Zaloguj się',
'chat.workStatus.mcp.failed': 'Niepowodzenie',
'chat.workStatus.pinned.unavailable': 'Przypięta wiadomość',
'chat.workStatus.section.session': 'Sesja',
'chat.workStatus.section.repository': 'Repozytorium',
'chat.workStatus.section.subagents': 'Subagenci',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Przypięte wiadomości',
'chat.workStatus.section.tasks': 'Zadania',
'chat.workStatus.subagent.working': 'pracuje',
'chat.workStatus.subagent.needsPermission': 'potrzebuje uprawnienia',
'chat.workStatus.subagent.askedQuestion': 'zadał pytanie',
'chat.workStatus.section.contextBreakdown': 'Źródła kontekstu',
'chat.workStatus.breakdown.skills': 'Umiejętności',
'chat.workStatus.breakdown.mcp': 'Serwery MCP',
'chat.workStatus.action.openChanges': 'Otwórz zmiany',
'chat.workStatus.action.openGit': 'Otwórz panel Git',
'chat.workStatus.action.openPr': 'Otwórz pull request',
'chat.workStatus.action.openSubagent': 'Otwórz {name}',
'chat.workStatus.section.usage': 'Zużycie',
'chat.workStatus.goal.open': 'Zarządzaj celem',
'chat.workStatus.goal.pause': 'Wstrzymaj',
'chat.workStatus.goal.resume': 'Wznów',
'chat.workStatus.goal.updateFailed': 'Nie udało się zaktualizować celu',
'chat.workStatus.pinned.unpin': 'Odepnij wiadomość',
'chat.workStatus.pinned.reveal': 'Przejdź do wiadomości',
'chat.workStatus.pinned.unpinFailed': 'Nie udało się odpiąć wiadomości',
'chat.workStatus.action.openContext': 'Otwórz panel kontekstu',
'chat.workStatus.section.linkedIssues': 'Powiązane',
'chat.workStatus.linkedIssues.open': 'Otwórz #{number} w GitHub',
'chat.workStatus.linkedIssues.unlink': 'Usuń powiązanie',
'chat.workStatus.linkedIssues.unlinkFailed': 'Nie udało się usunąć powiązania',
'chat.workStatus.linkedIssues.link': 'Powiąż z sesją',
'chat.workStatus.linkedIssues.linkFailed': 'Nie udało się powiązać',
'chat.workStatus.breakdown.issueCountSingle': '{count} zgłoszenie',
'chat.workStatus.breakdown.issueCountPlural': '{count} zgłoszeń',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PR-ów',
'chat.workStatus.breakdown.skillCountSingle': '{count} umiejętność',
'chat.workStatus.breakdown.skillCountPlural': '{count} umiejętności',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Wybierz sekcje',
'chat.workStatus.sections.dialogTitle': 'Sekcje panelu',
'chat.workStatus.sections.dialogDescription': 'Wybierz, co pokazuje panel stanu pracy. Ukryte sekcje zachowują swoje dane — po prostu nie są wyświetlane.',
'header.workStatusPanel.toggleAria': 'Przełącz panel stanu pracy',
'header.workStatusPanel.hide': 'Ukryj stan pracy',
'header.workStatusPanel.show': 'Pokaż stan pracy',
'chat.workStatus.mcp.authorizeOpenFailed': 'Nie udało się otworzyć strony autoryzacji',
'chat.workStatus.mcp.authorizeFailed': 'Autoryzacja nie powiodła się',
'mcpDropdown.toast.authorizeOpenFailed': 'Nie udało się otworzyć strony autoryzacji',
'mcpDropdown.toast.authorizeFailed': 'Autoryzacja nie powiodła się',
'header.services.tooltip.currentInstance': 'Bieżąca instancja: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Pokaż stan pracy nad czatem',
'settings.mcp.page.connection.title': 'Jak się połączyć',
'settings.mcp.page.connection.description': 'Wklej polecenie uruchamiające lub link do serwera hostowanego.',
'settings.mcp.page.registration.title': 'Ten serwer wymaga własnej aplikacji',
'settings.mcp.page.registration.description': 'Nie wydaje danych logowania automatycznie. Utwórz aplikację w ustawieniach usługi, wklej jej dane tutaj i zaloguj się.',
'settings.mcp.page.registration.callbackLabel': 'Podaj usłudze ten adres',
'settings.mcp.page.registration.clientId': 'ID aplikacji',
'settings.mcp.page.registration.clientSecret': 'Sekret aplikacji',
'settings.mcp.page.registration.afterSaving': 'Zapisz, a potem naciśnij Autoryzuj.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Skopiowano adres',
'settings.mcp.page.toast.clipboardWriteFailed': 'Nie udało się skopiować do schowka',
'settings.mcp.page.scope.everywhere': 'Dostępny we wszystkich projektach',
'settings.mcp.page.scope.thisProject': 'Tylko w tym projekcie',
'settings.mcp.page.env.description': 'Wartości potrzebne serwerowi, np. klucz API.',
'settings.mcp.page.connection.kindCommand': 'Polecenie',
'settings.mcp.page.connection.kindLink': 'Link',
'settings.mcp.page.connection.hintCommand': 'Działa na tym komputerze. Wklej całe polecenie — zostanie podzielone na jeden argument w wierszu.',
'settings.mcp.page.connection.hintLink': 'Łączy się z serwerem hostowanym przez kogoś innego. Wklej jego adres https.',
} as const;
+89 -3
View File
@@ -1523,8 +1523,6 @@ export const dict: Record<I18nKey, string> = {
"header.github.accountSource.cli": "CLI",
"header.services.openWithCurrent": "Abrir instância, uso e MCP (atual: {current})",
"header.services.open": "Abrir serviços, uso e MCP",
"header.services.tooltip.currentInstanceWithShortcuts": "Instância atual: {current} ({toggle}; próxima aba {nextTab})",
"header.services.tooltip.servicesWithShortcuts": "Serviços ({toggle}; próxima aba {nextTab})",
"header.services.title": "Serviços",
"header.services.viewAria": "Ver serviços",
"header.services.closeAria": "Fechar serviços",
@@ -1541,7 +1539,6 @@ export const dict: Record<I18nKey, string> = {
"header.services.used": "Usado",
"header.services.remaining": "Restante",
"header.services.modelFamily.other": "Outro",
"header.services.shutdownDev": "Parar OpenChamber",
"header.actions.openPlanAria": "Abrir plano",
"header.actions.toggleChangesPanel": "Painel de alterações",
"header.actions.toggleChangesPanelAria": "Alternar painel de alterações",
@@ -2943,4 +2940,93 @@ export const dict: Record<I18nKey, string> = {
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Status do trabalho',
'chat.workStatus.context.label': 'Contexto',
'chat.workStatus.git.changedFileSingle': '{count} arquivo alterado',
'chat.workStatus.git.changedFilePlural': '{count} arquivos alterados',
'chat.workStatus.pr.untitled': 'Pull request sem título',
'chat.workStatus.pr.draft': 'Rascunho',
'chat.workStatus.pr.checks': 'Verificações',
'chat.workStatus.pr.checksFailed': '{count} falharam',
'chat.workStatus.pr.checksPending': '{count} em execução',
'chat.workStatus.pr.checksPassed': '{count} aprovadas',
'chat.workStatus.attention.merge': 'Merge em andamento',
'chat.workStatus.attention.rebase': 'Rebase em andamento',
'chat.workStatus.attention.cherryPick': 'Cherry-pick em andamento',
'chat.workStatus.attention.revert': 'Revert em andamento',
'chat.workStatus.attention.bisect': 'Bisect em andamento',
'chat.workStatus.subagent.done': 'Concluído',
'chat.workStatus.subagent.untitled': 'Subagente',
'chat.workStatus.mcp.toggle': 'Alternar {name}',
'chat.workStatus.mcp.needsAuth': 'Entrar',
'chat.workStatus.mcp.failed': 'Falhou',
'chat.workStatus.pinned.unavailable': 'Mensagem fixada',
'chat.workStatus.section.session': 'Sessão',
'chat.workStatus.section.repository': 'Repositório',
'chat.workStatus.section.subagents': 'Subagentes',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Mensagens fixadas',
'chat.workStatus.section.tasks': 'Tarefas',
'chat.workStatus.subagent.working': 'está trabalhando',
'chat.workStatus.subagent.needsPermission': 'precisa de permissão',
'chat.workStatus.subagent.askedQuestion': 'fez uma pergunta',
'chat.workStatus.section.contextBreakdown': 'Fontes de contexto',
'chat.workStatus.breakdown.skills': 'Habilidades',
'chat.workStatus.breakdown.mcp': 'Servidores MCP',
'chat.workStatus.action.openChanges': 'Abrir alterações',
'chat.workStatus.action.openGit': 'Abrir painel do Git',
'chat.workStatus.action.openPr': 'Abrir pull request',
'chat.workStatus.action.openSubagent': 'Abrir {name}',
'chat.workStatus.section.usage': 'Uso',
'chat.workStatus.goal.open': 'Gerenciar objetivo',
'chat.workStatus.goal.pause': 'Pausar',
'chat.workStatus.goal.resume': 'Retomar',
'chat.workStatus.goal.updateFailed': 'Não foi possível atualizar o objetivo',
'chat.workStatus.pinned.unpin': 'Desafixar mensagem',
'chat.workStatus.pinned.reveal': 'Ir para a mensagem',
'chat.workStatus.pinned.unpinFailed': 'Não foi possível desafixar a mensagem',
'chat.workStatus.action.openContext': 'Abrir painel de contexto',
'chat.workStatus.section.linkedIssues': 'Vinculados',
'chat.workStatus.linkedIssues.open': 'Abrir #{number} no GitHub',
'chat.workStatus.linkedIssues.unlink': 'Remover vínculo',
'chat.workStatus.linkedIssues.unlinkFailed': 'Não foi possível remover o vínculo',
'chat.workStatus.linkedIssues.link': 'Vincular à sessão',
'chat.workStatus.linkedIssues.linkFailed': 'Não foi possível vincular',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PRs',
'chat.workStatus.breakdown.skillCountSingle': '{count} habilidade',
'chat.workStatus.breakdown.skillCountPlural': '{count} habilidades',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Escolher seções',
'chat.workStatus.sections.dialogTitle': 'Seções do painel',
'chat.workStatus.sections.dialogDescription': 'Escolha o que o painel de status mostra. Seções ocultas mantêm seus dados — apenas não aparecem no painel.',
'header.workStatusPanel.toggleAria': 'Alternar painel de status',
'header.workStatusPanel.hide': 'Ocultar status do trabalho',
'header.workStatusPanel.show': 'Mostrar status do trabalho',
'chat.workStatus.mcp.authorizeOpenFailed': 'Não foi possível abrir a página de autorização',
'chat.workStatus.mcp.authorizeFailed': 'Falha na autorização',
'mcpDropdown.toast.authorizeOpenFailed': 'Não foi possível abrir a página de autorização',
'mcpDropdown.toast.authorizeFailed': 'Falha na autorização',
'header.services.tooltip.currentInstance': 'Instância atual: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Mostrar status sobre o chat',
'settings.mcp.page.connection.title': 'Como acessar',
'settings.mcp.page.connection.description': 'Cole o comando que o inicia ou o link de um servidor hospedado.',
'settings.mcp.page.registration.title': 'Este servidor precisa do próprio app',
'settings.mcp.page.registration.description': 'Ele não fornece credenciais automaticamente. Crie um app nas configurações do serviço, cole os dados aqui e faça login.',
'settings.mcp.page.registration.callbackLabel': 'Informe este endereço ao serviço',
'settings.mcp.page.registration.clientId': 'ID do app',
'settings.mcp.page.registration.clientSecret': 'Segredo do app',
'settings.mcp.page.registration.afterSaving': 'Salve e depois pressione Autorizar.',
'settings.mcp.page.toast.copiedCallbackUrl': 'Endereço copiado',
'settings.mcp.page.toast.clipboardWriteFailed': 'Não foi possível copiar para a área de transferência',
'settings.mcp.page.scope.everywhere': 'Disponível em todos os projetos',
'settings.mcp.page.scope.thisProject': 'Somente neste projeto',
'settings.mcp.page.env.description': 'Valores que o servidor precisa, como uma chave de API.',
'settings.mcp.page.connection.kindCommand': 'Comando',
'settings.mcp.page.connection.kindLink': 'Link',
'settings.mcp.page.connection.hintCommand': 'Executa nesta máquina. Cole um comando inteiro e ele será dividido em um argumento por linha.',
'settings.mcp.page.connection.hintLink': 'Conecta a um servidor hospedado por outra pessoa. Cole o endereço https dele.',
};
+89 -3
View File
@@ -1523,8 +1523,6 @@ export const dict: Record<I18nKey, string> = {
"header.github.accountSource.cli": "CLI",
"header.services.openWithCurrent": "Відкрити інстанс, використання та MCP (поточний: {current})",
"header.services.open": "Відкрити сервіси, використання та MCP",
"header.services.tooltip.currentInstanceWithShortcuts": "Поточний інстанс: {current} ({toggle}; наступна вкладка {nextTab})",
"header.services.tooltip.servicesWithShortcuts": "Сервіси ({toggle}; наступна вкладка {nextTab})",
"header.services.title": "Сервіси",
"header.services.viewAria": "Переглянути сервіси",
"header.services.closeAria": "Закрити сервіси",
@@ -1540,7 +1538,6 @@ export const dict: Record<I18nKey, string> = {
"header.services.remoteUpdate.actions.open": "Оновити",
"header.services.used": "Використано",
"header.services.remaining": "Залишилося",
"header.services.shutdownDev": "Зупинити OpenChamber",
"header.services.modelFamily.other": "інше",
"header.actions.openPlanAria": "Відкрити план",
"header.actions.toggleChangesPanel": "Панель змін",
@@ -2943,4 +2940,93 @@ export const dict: Record<I18nKey, string> = {
"quota.window.chat": "Chat Requests",
"quota.window.completions": "Completions",
"quota.window.premiumInteractions": "Premium interactions",
'chat.workStatus.ariaLabel': 'Стан роботи',
'chat.workStatus.context.label': 'Контекст',
'chat.workStatus.git.changedFileSingle': 'Змінено {count} файл',
'chat.workStatus.git.changedFilePlural': 'Змінено {count} файлів',
'chat.workStatus.pr.untitled': 'Pull request без назви',
'chat.workStatus.pr.draft': 'Чернетка',
'chat.workStatus.pr.checks': 'Перевірки',
'chat.workStatus.pr.checksFailed': '{count} впало',
'chat.workStatus.pr.checksPending': '{count} виконується',
'chat.workStatus.pr.checksPassed': '{count} пройшло',
'chat.workStatus.attention.merge': 'Триває злиття',
'chat.workStatus.attention.rebase': 'Триває rebase',
'chat.workStatus.attention.cherryPick': 'Триває cherry-pick',
'chat.workStatus.attention.revert': 'Триває відкат',
'chat.workStatus.attention.bisect': 'Триває bisect',
'chat.workStatus.subagent.done': 'Готово',
'chat.workStatus.subagent.untitled': 'Сабагент',
'chat.workStatus.mcp.toggle': 'Перемкнути {name}',
'chat.workStatus.mcp.needsAuth': 'Увійти',
'chat.workStatus.mcp.failed': 'Помилка',
'chat.workStatus.pinned.unavailable': 'Запінене повідомлення',
'chat.workStatus.section.session': 'Сесія',
'chat.workStatus.section.repository': 'Репозиторій',
'chat.workStatus.section.subagents': 'Сабагенти',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': 'Запінені повідомлення',
'chat.workStatus.section.tasks': 'Задачі',
'chat.workStatus.subagent.working': 'працює',
'chat.workStatus.subagent.needsPermission': 'потребує дозволу',
'chat.workStatus.subagent.askedQuestion': 'поставив питання',
'chat.workStatus.section.contextBreakdown': 'Джерела контексту',
'chat.workStatus.breakdown.skills': 'Скіли',
'chat.workStatus.breakdown.mcp': 'Сервери MCP',
'chat.workStatus.action.openChanges': 'Відкрити зміни',
'chat.workStatus.action.openGit': 'Відкрити панель Git',
'chat.workStatus.action.openPr': 'Відкрити pull request',
'chat.workStatus.action.openSubagent': 'Відкрити {name}',
'chat.workStatus.section.usage': 'Використання',
'chat.workStatus.goal.open': 'Керувати ціллю',
'chat.workStatus.goal.pause': 'Пауза',
'chat.workStatus.goal.resume': 'Відновити',
'chat.workStatus.goal.updateFailed': 'Не вдалося оновити ціль',
'chat.workStatus.pinned.unpin': 'Відпінити повідомлення',
'chat.workStatus.pinned.reveal': 'Перейти до повідомлення',
'chat.workStatus.pinned.unpinFailed': 'Не вдалося відпінити повідомлення',
'chat.workStatus.action.openContext': 'Відкрити панель контексту',
'chat.workStatus.section.linkedIssues': 'Прилінковане',
'chat.workStatus.linkedIssues.open': 'Відкрити #{number} на GitHub',
'chat.workStatus.linkedIssues.unlink': 'Прибрати лінк',
'chat.workStatus.linkedIssues.unlinkFailed': 'Не вдалося прибрати лінк',
'chat.workStatus.linkedIssues.link': 'Прилінкувати до сесії',
'chat.workStatus.linkedIssues.linkFailed': 'Не вдалося прилінкувати',
'chat.workStatus.breakdown.issueCountSingle': '{count} issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} issues',
'chat.workStatus.breakdown.prCountSingle': '{count} PR',
'chat.workStatus.breakdown.prCountPlural': '{count} PR',
'chat.workStatus.breakdown.skillCountSingle': '{count} скіл',
'chat.workStatus.breakdown.skillCountPlural': '{count} скілів',
'chat.workStatus.breakdown.mcpCountSingle': '{count} MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} MCP',
'chat.workStatus.sections.open': 'Обрати секції',
'chat.workStatus.sections.dialogTitle': 'Секції панелі',
'chat.workStatus.sections.dialogDescription': 'Обери, що показує панель стану роботи. Приховані секції зберігають свої дані — вони просто не відображаються.',
'header.workStatusPanel.toggleAria': 'Перемкнути панель стану роботи',
'header.workStatusPanel.hide': 'Сховати стан роботи',
'header.workStatusPanel.show': 'Показати стан роботи',
'chat.workStatus.mcp.authorizeOpenFailed': 'Не вдалося відкрити сторінку авторизації',
'chat.workStatus.mcp.authorizeFailed': 'Авторизація не вдалася',
'mcpDropdown.toast.authorizeOpenFailed': 'Не вдалося відкрити сторінку авторизації',
'mcpDropdown.toast.authorizeFailed': 'Авторизація не вдалася',
'header.services.tooltip.currentInstance': 'Поточний інстанс: {current} ({toggle})',
'header.workStatusPanel.showOverlay': 'Показати стан роботи поверх чату',
'settings.mcp.page.connection.title': 'Як під’єднатись',
'settings.mcp.page.connection.description': 'Встав команду, яка його запускає, або посилання на готовий сервер.',
'settings.mcp.page.registration.title': 'Цьому серверу потрібен власний застосунок',
'settings.mcp.page.registration.description': 'Він не видає доступи автоматично. Створи застосунок у налаштуваннях самого сервісу, встав сюди його дані й увійди.',
'settings.mcp.page.registration.callbackLabel': 'Впиши цю адресу в сервісі',
'settings.mcp.page.registration.clientId': 'ID застосунку',
'settings.mcp.page.registration.clientSecret': 'Секрет застосунку',
'settings.mcp.page.registration.afterSaving': 'Збережи, потім натисни «Авторизувати».',
'settings.mcp.page.toast.copiedCallbackUrl': 'Адресу скопійовано',
'settings.mcp.page.toast.clipboardWriteFailed': 'Не вдалося скопіювати в буфер обміну',
'settings.mcp.page.scope.everywhere': 'Доступний у всіх проєктах',
'settings.mcp.page.scope.thisProject': 'Тільки в цьому проєкті',
'settings.mcp.page.env.description': 'Значення, потрібні серверу — наприклад, ключ API.',
'settings.mcp.page.connection.kindCommand': 'Команда',
'settings.mcp.page.connection.kindLink': 'Посилання',
'settings.mcp.page.connection.hintCommand': 'Запускається на цьому комп’ютері. Вставте цілу команду — вона розділиться на один аргумент у рядку.',
'settings.mcp.page.connection.hintLink': 'Під’єднується до сервера, який хостить хтось інший. Вставте його https-адресу.',
};
+89 -3
View File
@@ -1511,8 +1511,6 @@ export const dict: Record<I18nKey, string> = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': '打开实例、用量和 MCP(当前:{current}',
'header.services.open': '打开服务、用量和 MCP',
'header.services.tooltip.currentInstanceWithShortcuts': '当前实例:{current}{toggle};下一标签 {nextTab}',
'header.services.tooltip.servicesWithShortcuts': '服务({toggle};下一标签 {nextTab}',
'header.services.title': '服务',
'header.services.viewAria': '查看服务',
'header.services.closeAria': '关闭服务',
@@ -1528,7 +1526,6 @@ export const dict: Record<I18nKey, string> = {
'header.services.remoteUpdate.actions.open': '更新',
'header.services.used': '已用',
'header.services.remaining': '剩余',
'header.services.shutdownDev': '停止 OpenChamber',
'header.services.modelFamily.other': '其他',
'header.actions.openPlanAria': '打开计划',
"header.actions.toggleChangesPanel": "更改面板",
@@ -2943,4 +2940,93 @@ export const dict: Record<I18nKey, string> = {
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '工作状态',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.git.changedFileSingle': '已更改 {count} 个文件',
'chat.workStatus.git.changedFilePlural': '已更改 {count} 个文件',
'chat.workStatus.pr.untitled': '未命名的拉取请求',
'chat.workStatus.pr.draft': '草稿',
'chat.workStatus.pr.checks': '检查',
'chat.workStatus.pr.checksFailed': '{count} 项失败',
'chat.workStatus.pr.checksPending': '{count} 项进行中',
'chat.workStatus.pr.checksPassed': '{count} 项通过',
'chat.workStatus.attention.merge': '正在合并',
'chat.workStatus.attention.rebase': '正在变基',
'chat.workStatus.attention.cherryPick': '正在拣选提交',
'chat.workStatus.attention.revert': '正在还原',
'chat.workStatus.attention.bisect': '正在二分查找',
'chat.workStatus.subagent.done': '已完成',
'chat.workStatus.subagent.untitled': '子代理',
'chat.workStatus.mcp.toggle': '切换 {name}',
'chat.workStatus.mcp.needsAuth': '登录',
'chat.workStatus.mcp.failed': '失败',
'chat.workStatus.pinned.unavailable': '已固定的消息',
'chat.workStatus.section.session': '会话',
'chat.workStatus.section.repository': '仓库',
'chat.workStatus.section.subagents': '子代理',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': '已固定的消息',
'chat.workStatus.section.tasks': '任务',
'chat.workStatus.subagent.working': '正在工作',
'chat.workStatus.subagent.needsPermission': '需要授权',
'chat.workStatus.subagent.askedQuestion': '提出了问题',
'chat.workStatus.section.contextBreakdown': '上下文来源',
'chat.workStatus.breakdown.skills': '技能',
'chat.workStatus.breakdown.mcp': 'MCP 服务器',
'chat.workStatus.action.openChanges': '打开更改',
'chat.workStatus.action.openGit': '打开 Git 面板',
'chat.workStatus.action.openPr': '打开拉取请求',
'chat.workStatus.action.openSubagent': '打开 {name}',
'chat.workStatus.section.usage': '用量',
'chat.workStatus.goal.open': '管理目标',
'chat.workStatus.goal.pause': '暂停',
'chat.workStatus.goal.resume': '继续',
'chat.workStatus.goal.updateFailed': '无法更新目标',
'chat.workStatus.pinned.unpin': '取消固定消息',
'chat.workStatus.pinned.reveal': '跳转到消息',
'chat.workStatus.pinned.unpinFailed': '无法取消固定消息',
'chat.workStatus.action.openContext': '打开上下文面板',
'chat.workStatus.section.linkedIssues': '已关联',
'chat.workStatus.linkedIssues.open': '在 GitHub 上打开 #{number}',
'chat.workStatus.linkedIssues.unlink': '移除关联',
'chat.workStatus.linkedIssues.unlinkFailed': '无法移除关联',
'chat.workStatus.linkedIssues.link': '关联到会话',
'chat.workStatus.linkedIssues.linkFailed': '无法关联',
'chat.workStatus.breakdown.issueCountSingle': '{count} 个 issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} 个 issue',
'chat.workStatus.breakdown.prCountSingle': '{count} 个 PR',
'chat.workStatus.breakdown.prCountPlural': '{count} 个 PR',
'chat.workStatus.breakdown.skillCountSingle': '{count} 个技能',
'chat.workStatus.breakdown.skillCountPlural': '{count} 个技能',
'chat.workStatus.breakdown.mcpCountSingle': '{count} 个 MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} 个 MCP',
'chat.workStatus.sections.open': '选择板块',
'chat.workStatus.sections.dialogTitle': '面板板块',
'chat.workStatus.sections.dialogDescription': '选择工作状态面板显示的内容。隐藏的板块仍保留数据,只是不再显示。',
'header.workStatusPanel.toggleAria': '切换工作状态面板',
'header.workStatusPanel.hide': '隐藏工作状态',
'header.workStatusPanel.show': '显示工作状态',
'chat.workStatus.mcp.authorizeOpenFailed': '无法打开授权页面',
'chat.workStatus.mcp.authorizeFailed': '授权失败',
'mcpDropdown.toast.authorizeOpenFailed': '无法打开授权页面',
'mcpDropdown.toast.authorizeFailed': '授权失败',
'header.services.tooltip.currentInstance': '当前实例:{current}{toggle}',
'header.workStatusPanel.showOverlay': '在聊天上方显示工作状态',
'settings.mcp.page.connection.title': '如何连接',
'settings.mcp.page.connection.description': '粘贴启动命令,或托管服务器的链接。',
'settings.mcp.page.registration.title': '此服务器需要独立应用',
'settings.mcp.page.registration.description': '它不会自动下发凭据。请在该服务的设置中创建应用,把信息粘贴到这里,然后登录。',
'settings.mcp.page.registration.callbackLabel': '把这个地址填入该服务',
'settings.mcp.page.registration.clientId': '应用 ID',
'settings.mcp.page.registration.clientSecret': '应用密钥',
'settings.mcp.page.registration.afterSaving': '保存后点击“授权”。',
'settings.mcp.page.toast.copiedCallbackUrl': '已复制地址',
'settings.mcp.page.toast.clipboardWriteFailed': '无法复制到剪贴板',
'settings.mcp.page.scope.everywhere': '在所有项目中可用',
'settings.mcp.page.scope.thisProject': '仅在此项目中',
'settings.mcp.page.env.description': '服务器需要的值,例如 API 密钥。',
'settings.mcp.page.connection.kindCommand': '命令',
'settings.mcp.page.connection.kindLink': '链接',
'settings.mcp.page.connection.hintCommand': '在本机运行。粘贴完整命令后会按每行一个参数拆分。',
'settings.mcp.page.connection.hintLink': '连接到他人托管的服务器。粘贴其 https 地址。',
};
+89 -3
View File
@@ -1521,8 +1521,6 @@ export const dict: Record<I18nKey, string> = {
'header.github.accountSource.cli': 'CLI',
'header.services.openWithCurrent': '開啟實例、用量和 MCP(目前:{current}',
'header.services.open': '開啟服務、用量和 MCP',
'header.services.tooltip.currentInstanceWithShortcuts': '目前實例:{current}{toggle};下一分頁 {nextTab}',
'header.services.tooltip.servicesWithShortcuts': '服務({toggle};下一分頁 {nextTab}',
'header.services.title': '服務',
'header.services.viewAria': '查看服務',
'header.services.closeAria': '關閉服務',
@@ -1532,7 +1530,6 @@ export const dict: Record<I18nKey, string> = {
'header.services.noRateLimitsReported': '未報告速率限制。',
'header.services.used': '已用',
'header.services.remaining': '剩餘',
'header.services.shutdownDev': '停止 OpenChamber',
'header.services.modelFamily.other': '其他',
'header.actions.openPlanAria': '開啟計畫',
"header.actions.toggleChangesPanel": "變更面板",
@@ -2942,4 +2939,93 @@ export const dict: Record<I18nKey, string> = {
'quota.window.chat': 'Chat Requests',
'quota.window.completions': 'Completions',
'quota.window.premiumInteractions': 'Premium interactions',
'chat.workStatus.ariaLabel': '工作狀態',
'chat.workStatus.context.label': '上下文',
'chat.workStatus.git.changedFileSingle': '已變更 {count} 個檔案',
'chat.workStatus.git.changedFilePlural': '已變更 {count} 個檔案',
'chat.workStatus.pr.untitled': '未命名的提取請求',
'chat.workStatus.pr.draft': '草稿',
'chat.workStatus.pr.checks': '檢查',
'chat.workStatus.pr.checksFailed': '{count} 項失敗',
'chat.workStatus.pr.checksPending': '{count} 項進行中',
'chat.workStatus.pr.checksPassed': '{count} 項通過',
'chat.workStatus.attention.merge': '正在合併',
'chat.workStatus.attention.rebase': '正在變基',
'chat.workStatus.attention.cherryPick': '正在揀選提交',
'chat.workStatus.attention.revert': '正在還原',
'chat.workStatus.attention.bisect': '正在二分搜尋',
'chat.workStatus.subagent.done': '已完成',
'chat.workStatus.subagent.untitled': '子代理',
'chat.workStatus.mcp.toggle': '切換 {name}',
'chat.workStatus.mcp.needsAuth': '登入',
'chat.workStatus.mcp.failed': '失敗',
'chat.workStatus.pinned.unavailable': '已釘選的訊息',
'chat.workStatus.section.session': '工作階段',
'chat.workStatus.section.repository': '儲存庫',
'chat.workStatus.section.subagents': '子代理',
'chat.workStatus.section.mcp': 'MCP',
'chat.workStatus.section.pinned': '已釘選的訊息',
'chat.workStatus.section.tasks': '工作',
'chat.workStatus.subagent.working': '正在工作',
'chat.workStatus.subagent.needsPermission': '需要授權',
'chat.workStatus.subagent.askedQuestion': '提出了問題',
'chat.workStatus.section.contextBreakdown': '上下文來源',
'chat.workStatus.breakdown.skills': '技能',
'chat.workStatus.breakdown.mcp': 'MCP 伺服器',
'chat.workStatus.action.openChanges': '開啟變更',
'chat.workStatus.action.openGit': '開啟 Git 面板',
'chat.workStatus.action.openPr': '開啟提取請求',
'chat.workStatus.action.openSubagent': '開啟 {name}',
'chat.workStatus.section.usage': '用量',
'chat.workStatus.goal.open': '管理目標',
'chat.workStatus.goal.pause': '暫停',
'chat.workStatus.goal.resume': '繼續',
'chat.workStatus.goal.updateFailed': '無法更新目標',
'chat.workStatus.pinned.unpin': '取消釘選訊息',
'chat.workStatus.pinned.reveal': '跳至訊息',
'chat.workStatus.pinned.unpinFailed': '無法取消釘選訊息',
'chat.workStatus.action.openContext': '開啟上下文面板',
'chat.workStatus.section.linkedIssues': '已關聯',
'chat.workStatus.linkedIssues.open': '在 GitHub 上開啟 #{number}',
'chat.workStatus.linkedIssues.unlink': '移除關聯',
'chat.workStatus.linkedIssues.unlinkFailed': '無法移除關聯',
'chat.workStatus.linkedIssues.link': '關聯到工作階段',
'chat.workStatus.linkedIssues.linkFailed': '無法關聯',
'chat.workStatus.breakdown.issueCountSingle': '{count} 個 issue',
'chat.workStatus.breakdown.issueCountPlural': '{count} 個 issue',
'chat.workStatus.breakdown.prCountSingle': '{count} 個 PR',
'chat.workStatus.breakdown.prCountPlural': '{count} 個 PR',
'chat.workStatus.breakdown.skillCountSingle': '{count} 個技能',
'chat.workStatus.breakdown.skillCountPlural': '{count} 個技能',
'chat.workStatus.breakdown.mcpCountSingle': '{count} 個 MCP',
'chat.workStatus.breakdown.mcpCountPlural': '{count} 個 MCP',
'chat.workStatus.sections.open': '選擇區塊',
'chat.workStatus.sections.dialogTitle': '面板區塊',
'chat.workStatus.sections.dialogDescription': '選擇工作狀態面板顯示的內容。隱藏的區塊仍保留資料,只是不再顯示。',
'header.workStatusPanel.toggleAria': '切換工作狀態面板',
'header.workStatusPanel.hide': '隱藏工作狀態',
'header.workStatusPanel.show': '顯示工作狀態',
'chat.workStatus.mcp.authorizeOpenFailed': '無法開啟授權頁面',
'chat.workStatus.mcp.authorizeFailed': '授權失敗',
'mcpDropdown.toast.authorizeOpenFailed': '無法開啟授權頁面',
'mcpDropdown.toast.authorizeFailed': '授權失敗',
'header.services.tooltip.currentInstance': '目前執行個體:{current}{toggle}',
'header.workStatusPanel.showOverlay': '在聊天上方顯示工作狀態',
'settings.mcp.page.connection.title': '如何連線',
'settings.mcp.page.connection.description': '貼上啟動指令,或代管伺服器的連結。',
'settings.mcp.page.registration.title': '此伺服器需要獨立應用程式',
'settings.mcp.page.registration.description': '它不會自動發放憑證。請在該服務的設定中建立應用程式,將資訊貼到這裡,然後登入。',
'settings.mcp.page.registration.callbackLabel': '把這個位址填入該服務',
'settings.mcp.page.registration.clientId': '應用程式 ID',
'settings.mcp.page.registration.clientSecret': '應用程式密鑰',
'settings.mcp.page.registration.afterSaving': '儲存後點擊「授權」。',
'settings.mcp.page.toast.copiedCallbackUrl': '已複製位址',
'settings.mcp.page.toast.clipboardWriteFailed': '無法複製到剪貼簿',
'settings.mcp.page.scope.everywhere': '在所有專案中可用',
'settings.mcp.page.scope.thisProject': '僅在此專案中',
'settings.mcp.page.env.description': '伺服器需要的值,例如 API 金鑰。',
'settings.mcp.page.connection.kindCommand': '指令',
'settings.mcp.page.connection.kindLink': '連結',
'settings.mcp.page.connection.hintCommand': '在本機執行。貼上完整指令後會依每行一個參數拆分。',
'settings.mcp.page.connection.hintLink': '連線到他人代管的伺服器。貼上其 https 位址。',
};
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { buildLinkedIssue, buildLinkedIssueId, getLinkedIssues, withLinkedIssue, type LinkedIssue } from './linkedIssues';
const issue = (overrides: Partial<LinkedIssue> = {}): LinkedIssue => ({
id: 'owner/repo#12',
number: 12,
title: 'Rail badge count',
url: 'https://github.com/owner/repo/issues/12',
kind: 'issue',
author: 'someone',
linkedAt: 1,
...overrides,
});
const sessionWith = (linked: unknown): Session =>
({ metadata: { openchamber: { linked_issues: linked } } } as unknown as Session);
describe('buildLinkedIssueId', () => {
test('is stable per repository and number', () => {
expect(buildLinkedIssueId('owner', 'repo', 12)).toBe('owner/repo#12');
});
});
describe('buildLinkedIssue', () => {
test('derives the id from the thread url', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/12',
number: 12,
title: 'Rail badge count',
kind: 'issue',
author: { login: 'someone', avatarUrl: 'https://avatars/1' },
linkedAt: 5,
});
expect(built.id).toBe('owner/repo#12');
expect(built.author).toBe('someone');
expect(built.authorAvatarUrl).toBe('https://avatars/1');
});
test('gives a pull request the same id shape as an issue', () => {
// Both live in one numbering space per repository, so one id shape keeps
// them from colliding or duplicating.
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/pull/7',
number: 7,
title: 'Fix',
kind: 'pull',
linkedAt: 5,
});
expect(built.id).toBe('owner/repo#7');
expect(built.kind).toBe('pull');
});
test('falls back to a url-based id for an unparseable url', () => {
const built = buildLinkedIssue({
url: 'https://ghe.internal/x',
number: 3,
title: 'Internal',
kind: 'issue',
linkedAt: 5,
});
expect(built.id).toBe('https://ghe.internal/x#3');
});
test('omits author fields when the flow has none', () => {
const built = buildLinkedIssue({
url: 'https://github.com/owner/repo/issues/1',
number: 1,
title: 'No author',
kind: 'issue',
author: null,
linkedAt: 5,
});
expect(built.author).toBe(undefined);
expect(built.authorAvatarUrl).toBe(undefined);
});
});
describe('getLinkedIssues', () => {
test('returns an empty list for a session with no metadata', () => {
expect(getLinkedIssues(undefined)).toEqual([]);
expect(getLinkedIssues({} as Session)).toEqual([]);
expect(getLinkedIssues(sessionWith(undefined))).toEqual([]);
});
test('drops malformed entries instead of rendering them', () => {
const good = issue();
const session = sessionWith([
good,
{ id: 'no-number' },
{ ...good, id: 'owner/repo#13', kind: 'discussion' },
null,
'string',
]);
expect(getLinkedIssues(session)).toEqual([good]);
});
test('survives a non-array payload', () => {
expect(getLinkedIssues(sessionWith({ nope: true }))).toEqual([]);
});
});
describe('withLinkedIssue', () => {
test('adds a link and preserves unrelated metadata', () => {
const next = withLinkedIssue(
{ openchamber: { kind: 'review' }, other: 1 },
issue(),
true,
);
expect(next.other).toBe(1);
expect((next.openchamber as Record<string, unknown>).kind).toBe('review');
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]);
});
test('re-linking replaces the entry rather than duplicating it', () => {
// Linking again is how a drifted title gets refreshed.
const first = withLinkedIssue({}, issue({ title: 'Old' }), true);
const second = withLinkedIssue(first, issue({ title: 'New' }), true);
const stored = (second.openchamber as { linked_issues: LinkedIssue[] }).linked_issues;
expect(stored).toHaveLength(1);
expect(stored[0].title).toBe('New');
});
test('unlinking removes only the matching id', () => {
const other = issue({ id: 'owner/repo#99', number: 99 });
const both = withLinkedIssue(withLinkedIssue({}, issue(), true), other, true);
const next = withLinkedIssue(both, issue(), false);
const stored = (next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues;
expect(stored).toEqual([other]);
});
test('unlinking something absent is a no-op, not an error', () => {
const next = withLinkedIssue({}, issue(), false);
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([]);
});
test('does not carry malformed stored entries forward', () => {
const next = withLinkedIssue(
{ openchamber: { linked_issues: [{ id: 'broken' }] } },
issue(),
true,
);
expect((next.openchamber as { linked_issues: LinkedIssue[] }).linked_issues).toEqual([issue()]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
/**
* GitHub issues and pull requests a user has linked to a session.
*
* Stored as a **snapshot**, not a reference: number, title, author and avatar
* only. Enough to render a row and open the thing, and nothing more the body,
* comments and state of an issue belong to GitHub, and mirroring them here
* would mean owning their staleness. The stored title can drift from the real
* one; that is the accepted cost of a storage that never needs refreshing.
*
* Rides the same session-metadata channel as pinned messages
* (`contextObligatoryMessages`), so it inherits their persistence and sync for
* free.
*/
export type LinkedIssue = {
/** `owner/repo#number`, unique per session and stable across renames. */
id: string;
number: number;
title: string;
url: string;
kind: 'issue' | 'pull';
author?: string;
authorAvatarUrl?: string;
linkedAt: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const isLinkedIssue = (value: unknown): value is LinkedIssue => (
isRecord(value)
&& typeof value.id === 'string'
&& value.id.length > 0
&& typeof value.number === 'number'
&& Number.isFinite(value.number)
&& typeof value.title === 'string'
&& typeof value.url === 'string'
&& (value.kind === 'issue' || value.kind === 'pull')
&& typeof value.linkedAt === 'number'
&& Number.isFinite(value.linkedAt)
);
export const buildLinkedIssueId = (owner: string, repo: string, number: number): string =>
`${owner}/${repo}#${number}`;
/**
* Builds the stored snapshot from what an attach flow already has.
*
* The id comes from the URL rather than a separate owner/repo pair: every flow
* that attaches a thread has its URL, and only some of them carry the repo
* separately. A URL that does not parse falls back to itself, which is still
* unique per thread the id only has to identify an entry, not be pretty.
*/
export const buildLinkedIssue = (input: {
url: string;
number: number;
title: string;
kind: 'issue' | 'pull';
author?: { login?: string; avatarUrl?: string } | null;
linkedAt: number;
}): LinkedIssue => {
const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url);
const id = match
? buildLinkedIssueId(match[1], match[2], input.number)
: `${input.url}#${input.number}`;
return {
id,
number: input.number,
title: input.title,
url: input.url,
kind: input.kind,
author: input.author?.login ?? undefined,
authorAvatarUrl: input.author?.avatarUrl ?? undefined,
linkedAt: input.linkedAt,
};
};
export const getLinkedIssues = (session: Session | null | undefined): LinkedIssue[] => {
const openchamber = getSessionMetadata(session).openchamber;
if (!isRecord(openchamber) || !Array.isArray(openchamber.linked_issues)) return [];
// Malformed entries are dropped rather than rendered: a half-written link
// has no row worth showing.
return openchamber.linked_issues.filter(isLinkedIssue);
};
export const withLinkedIssue = (
metadata: SessionMetadataRecord,
issue: LinkedIssue,
linked: boolean,
): SessionMetadataRecord => {
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const current = Array.isArray(openchamber.linked_issues)
? openchamber.linked_issues.filter(isLinkedIssue)
: [];
const withoutIssue = current.filter((entry) => entry.id !== issue.id);
// Re-linking an existing entry replaces it, so a stale title can be refreshed
// by linking again.
const next = linked ? [...withoutIssue, issue] : withoutIssue;
return {
...metadata,
openchamber: {
...openchamber,
linked_issues: next,
},
};
};
+21
View File
@@ -1,4 +1,5 @@
import type { DesktopSettings } from '@/lib/desktop';
import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/sections';
import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore';
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
@@ -524,6 +525,8 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
workStatusHiddenSections: defaults.workStatusHiddenSections,
sessionRecapEnabled: defaults.sessionRecapEnabled,
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
sessionGoalEnabled: defaults.sessionGoalEnabled,
@@ -614,6 +617,16 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
: null;
const queueStore = useMessageQueueStore.getState();
if (typeof settings.workStatusPanelEnabled === 'boolean'
&& settings.workStatusPanelEnabled !== store.workStatusPanelEnabled) {
store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled);
}
if (Array.isArray(settings.workStatusHiddenSections)) {
const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections);
if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000')) {
store.setWorkStatusHiddenSections(next);
}
}
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
@@ -1075,6 +1088,14 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') {
result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded;
}
if (typeof candidate.workStatusPanelEnabled === 'boolean') {
result.workStatusPanelEnabled = candidate.workStatusPanelEnabled;
}
if (Array.isArray(candidate.workStatusHiddenSections)) {
// Unknown ids are dropped rather than kept: they would hide nothing and
// accumulate forever as sections get renamed.
result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections);
}
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test';
import { filterSkillsByRuntimeFlags, resolveSkillRoot } from './skillVisibility';
const skill = (name: string, path: string) => ({ name, path });
const AGENTS = (name: string) => skill(name, `/repo/.agents/skills/${name}/SKILL.md`);
const CLAUDE = (name: string) => skill(name, `/repo/.claude/skills/${name}/SKILL.md`);
const OPENCODE = (name: string) => skill(name, `/home/u/.config/opencode/skill/${name}/SKILL.md`);
const ENABLED = { claudeDisabled: false, allDisabled: false };
describe('resolveSkillRoot', () => {
test('classifies the external roots and everything else', () => {
expect(resolveSkillRoot('/repo/.claude/skills/a/SKILL.md')).toBe('claude');
expect(resolveSkillRoot('/repo/.agents/skills/a/SKILL.md')).toBe('agents');
expect(resolveSkillRoot('/repo/.opencode/skills/a/SKILL.md')).toBe('opencode');
expect(resolveSkillRoot('/home/u/.config/opencode/skill/a/SKILL.md')).toBe('opencode');
});
test('does not match a directory that merely contains the name', () => {
expect(resolveSkillRoot('/repo/my.claude.backup/skills/a/SKILL.md')).toBe('opencode');
});
});
describe('filterSkillsByRuntimeFlags', () => {
test('passes everything through when the server reported no flags', () => {
// An older server or a failed read must not hide skills that do work.
const skills = [AGENTS('a'), CLAUDE('b'), OPENCODE('c')];
expect(filterSkillsByRuntimeFlags(skills, null)).toHaveLength(3);
});
test('keeps every root when nothing is disabled', () => {
const result = filterSkillsByRuntimeFlags([AGENTS('a'), CLAUDE('b'), OPENCODE('c')], ENABLED);
expect(result.map((s) => s.name).sort()).toEqual(['a', 'b', 'c']);
});
test('drops .claude but keeps .agents when claude skills are disabled', () => {
// The specific flag governs `.claude` alone; `.agents` is always scanned.
const result = filterSkillsByRuntimeFlags(
[AGENTS('a'), CLAUDE('b'), OPENCODE('c')],
{ claudeDisabled: true, allDisabled: false },
);
expect(result.map((s) => s.name).sort()).toEqual(['a', 'c']);
});
test('drops both external roots when external skills are disabled', () => {
const result = filterSkillsByRuntimeFlags(
[AGENTS('a'), CLAUDE('b'), OPENCODE('c')],
{ claudeDisabled: false, allDisabled: true },
);
expect(result.map((s) => s.name)).toEqual(['c']);
});
test('prefers the .agents copy when a name exists in both roots', () => {
// `.claude/skills` entries are commonly symlinks into `.agents/skills`;
// OpenCode scans `.agents` last, so it wins the collision.
const result = filterSkillsByRuntimeFlags([CLAUDE('dup'), AGENTS('dup')], ENABLED);
expect(result).toHaveLength(1);
expect(result[0].path).toContain('.agents');
});
test('keeps the single surviving copy of a duplicated name when .claude is disabled', () => {
const result = filterSkillsByRuntimeFlags(
[CLAUDE('dup'), AGENTS('dup')],
{ claudeDisabled: true, allDisabled: false },
);
expect(result).toHaveLength(1);
expect(result[0].path).toContain('.agents');
});
test('does not let dedup drop a name that only exists under .claude', () => {
const result = filterSkillsByRuntimeFlags([CLAUDE('only-claude'), AGENTS('other')], ENABLED);
expect(result.map((s) => s.name).sort()).toEqual(['only-claude', 'other']);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Narrowing the discovered skill list to what the agent can actually invoke.
*
* OpenChamber scans every root it knows about. OpenCode loads a narrower set,
* governed by environment flags the browser cannot read the server reports
* them alongside the scan.
*
* OpenCode's own skill-list endpoint cannot serve as the authority here:
* measured against 1.18.14, it returns only global and builtin skills and omits
* the project `.agents`/`.claude` skills the agent demonstrably has. Mirroring
* its discovery rules is the only way to match what the agent sees.
*
* The rules, from `opencode/src/skill/index.ts`:
*
* - `OPENCODE_DISABLE_EXTERNAL_SKILLS` drops `.claude` and `.agents` entirely;
* - `OPENCODE_DISABLE_CLAUDE_CODE` (broad) or `..._CLAUDE_CODE_SKILLS`
* (specific) drops `.claude` only `.agents` is always scanned;
* - names are deduplicated, last scan winning, and `.agents` is scanned after
* `.claude`, so `.agents` wins a collision. This matters here because
* `.claude/skills` entries are commonly symlinks back into `.agents/skills`.
*/
type ExternalSkillFlags = {
/** `.claude` roots are not loaded. */
claudeDisabled: boolean;
/** Neither `.claude` nor `.agents` roots are loaded. */
allDisabled: boolean;
};
type SkillLike = { name: string; path: string };
const CLAUDE_ROOT = /(^|\/)\.claude\//;
const AGENTS_ROOT = /(^|\/)\.agents\//;
type SkillRoot = 'claude' | 'agents' | 'opencode';
export const resolveSkillRoot = (skillPath: string): SkillRoot => {
if (CLAUDE_ROOT.test(skillPath)) return 'claude';
if (AGENTS_ROOT.test(skillPath)) return 'agents';
return 'opencode';
};
export const filterSkillsByRuntimeFlags = <T extends SkillLike>(
skills: readonly T[],
flags: ExternalSkillFlags | null | undefined,
): T[] => {
// No flags reported means an older server or a failed read. Filtering on a
// guess would hide skills that do work, so the list passes through.
if (!flags) return [...skills];
const allowed = skills.filter((skill) => {
const root = resolveSkillRoot(skill.path);
if (root === 'opencode') return true;
if (flags.allDisabled) return false;
if (root === 'claude') return !flags.claudeDisabled;
return true;
});
// Deduplicate by name, preferring `.agents` — the same order OpenCode's
// last-write-wins scan produces.
const byName = new Map<string, T>();
for (const skill of allowed) {
const existing = byName.get(skill.name);
if (!existing) {
byName.set(skill.name, skill);
continue;
}
if (resolveSkillRoot(existing.path) === 'claude' && resolveSkillRoot(skill.path) === 'agents') {
byName.set(skill.name, skill);
}
}
return [...byName.values()];
};
+9
View File
@@ -177,6 +177,7 @@ export const useMcpStore = create<McpStore>()(
return authorizationUrl;
},
completeAuth: async (name, code, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = getMcpApiClient(normalized);
@@ -188,6 +189,14 @@ export const useMcpStore = create<McpStore>()(
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = getMcpApiClient(normalized);
await api.mcp.auth.remove({ name }, { throwOnError: true });
// Removing the stored tokens does not touch the live session, so the
// server kept reporting `connected` until something forced a reconnect —
// the user had to run a connection test to see that authorization was
// gone. Dropping the connection makes the reported state match the
// credentials that remain.
await api.mcp.disconnect({ name }).catch(() => undefined);
await get().refresh({ directory: normalized, silent: true });
},
+16 -1
View File
@@ -14,6 +14,7 @@ import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client';
import { filterSkillsByRuntimeFlags } from './skillVisibility';
// Prefer the active project path so Settings/Skills discovery matches the
// project selector (and Commands/Agents). Falling back only to the session
@@ -291,7 +292,21 @@ export const useSkillsStore = create<SkillsStore>()(
renamable: s.renamable === true,
}));
set({ skills: configSkills, isLoading: false });
// OpenCode loads a narrower set than this scan finds, and the
// rules live in server-side env flags the browser cannot read.
// The route reports them; `filterSkillsByRuntimeFlags` mirrors
// OpenCode's discovery, including the `.agents`-wins dedup that
// matters when `.claude/skills` are symlinks back into it.
//
// Deliberately not OpenCode's own skill endpoint: measured
// against 1.18.14 it lists only global and builtin skills and
// omits the project skills the agent actually has.
const visibleSkills = filterSkillsByRuntimeFlags(
configSkills,
data.externalSkills ?? null,
);
set({ skills: visibleSkills, isLoading: false });
skillsLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
+115
View File
@@ -578,6 +578,33 @@ interface UIStore {
contextEditorTreeWidth: number;
notesPanelHeight: number;
todoPanelHeight: number;
/** Expanded collapsible sections of the in-chat work-status panel, by id. */
workStatusExpandedSections: Record<string, boolean>;
/** Scroll offset of that panel, so it survives being unmounted. */
workStatusScrollTop: number;
/** Whether the in-chat work-status panel may render at all. */
workStatusPanelEnabled: boolean;
/**
* Whether it is actually on screen right now the switch can be on while
* layout still refuses it (narrow chat, open context panel). Transient, never
* persisted: it describes the current frame, not a preference. The header
* reads it to stop repeating what the panel already shows.
*/
workStatusPanelVisible: boolean;
/** Layout can host the panel inline. Transient, like the one above. */
workStatusPanelFits: boolean;
/**
* Shown over the chat because it does not fit beside it. Transient and never
* persisted: it is a response to the current window, not a preference, and
* the panel returns to its place as soon as there is room.
*/
workStatusOverlayOpen: boolean;
/**
* Sections the user switched off. Hidden rather than visible ones are
* stored, so a section added later appears without touching saved settings.
* Persisted to server settings, not just this browser.
*/
workStatusHiddenSections: string[];
isSessionSwitcherOpen: boolean;
isSessionDropdownOpen: boolean;
activeMainTab: MainTab;
@@ -739,6 +766,14 @@ interface UIStore {
toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
setNotesPanelHeight: (height: number) => void;
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void;
setWorkStatusScrollTop: (scrollTop: number) => void;
setWorkStatusPanelEnabled: (enabled: boolean) => void;
setWorkStatusPanelVisible: (visible: boolean) => void;
setWorkStatusPanelFits: (fits: boolean) => void;
setWorkStatusOverlayOpen: (open: boolean) => void;
setWorkStatusSectionVisible: (sectionId: string, visible: boolean) => void;
setWorkStatusHiddenSections: (sectionIds: string[]) => void;
setTodoPanelHeight: (height: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setSessionDropdownOpen: (open: boolean) => void;
@@ -904,6 +939,13 @@ export const useUIStore = create<UIStore>()(
contextEditorTreeVisible: true,
contextEditorTreeWidth: 240,
notesPanelHeight: 112,
workStatusExpandedSections: {},
workStatusScrollTop: 0,
workStatusPanelEnabled: true,
workStatusPanelVisible: false,
workStatusPanelFits: false,
workStatusOverlayOpen: false,
workStatusHiddenSections: [],
todoPanelHeight: 259,
isSessionSwitcherOpen: false,
isSessionDropdownOpen: false,
@@ -1438,6 +1480,63 @@ export const useUIStore = create<UIStore>()(
set({ notesPanelHeight: height });
},
setWorkStatusSectionExpanded: (sectionId, expanded) => {
set((state) => (
state.workStatusExpandedSections[sectionId] === expanded
? state
: {
workStatusExpandedSections: {
...state.workStatusExpandedSections,
[sectionId]: expanded,
},
}
));
},
setWorkStatusScrollTop: (scrollTop) => {
set({ workStatusScrollTop: Math.max(0, scrollTop) });
},
setWorkStatusPanelEnabled: (enabled) => {
set({ workStatusPanelEnabled: enabled });
},
setWorkStatusPanelVisible: (visible) => {
set((state) => (state.workStatusPanelVisible === visible ? state : { workStatusPanelVisible: visible }));
},
setWorkStatusPanelFits: (fits) => {
set((state) => {
if (state.workStatusPanelFits === fits) return state;
// Room again: the panel goes back to its place, so an overlay left
// open would duplicate it.
return fits
? { workStatusPanelFits: true, workStatusOverlayOpen: false }
: { workStatusPanelFits: false };
});
},
setWorkStatusOverlayOpen: (open) => {
set((state) => (state.workStatusOverlayOpen === open ? state : { workStatusOverlayOpen: open }));
},
setWorkStatusSectionVisible: (sectionId, visible) => {
set((state) => {
const hidden = state.workStatusHiddenSections;
const isHidden = hidden.includes(sectionId);
if (visible === !isHidden) return state;
return {
workStatusHiddenSections: visible
? hidden.filter((entry) => entry !== sectionId)
: [...hidden, sectionId],
};
});
},
setWorkStatusHiddenSections: (sectionIds) => {
set({ workStatusHiddenSections: [...new Set(sectionIds)] });
},
setTodoPanelHeight: (height) => {
set({ todoPanelHeight: height });
},
@@ -2314,6 +2413,18 @@ export const useUIStore = create<UIStore>()(
// v8 -> v9: initialize notes/todo panel height fields
if (version < 9) {
if (!state.workStatusExpandedSections || typeof state.workStatusExpandedSections !== 'object') {
state.workStatusExpandedSections = {};
}
if (typeof state.workStatusScrollTop !== 'number' || !Number.isFinite(state.workStatusScrollTop)) {
state.workStatusScrollTop = 0;
}
if (typeof state.workStatusPanelEnabled !== 'boolean') {
state.workStatusPanelEnabled = true;
}
if (!Array.isArray(state.workStatusHiddenSections)) {
state.workStatusHiddenSections = [];
}
if (typeof state.notesPanelHeight !== 'number' || !Number.isFinite(state.notesPanelHeight)) {
state.notesPanelHeight = 112;
}
@@ -2413,6 +2524,10 @@ export const useUIStore = create<UIStore>()(
contextEditorTreeVisible: state.contextEditorTreeVisible,
contextEditorTreeWidth: state.contextEditorTreeWidth,
notesPanelHeight: state.notesPanelHeight,
workStatusExpandedSections: state.workStatusExpandedSections,
workStatusScrollTop: state.workStatusScrollTop,
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
todoPanelHeight: state.todoPanelHeight,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
+14
View File
@@ -26,6 +26,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { getRuntimeKey } from "@/lib/runtime-switch"
@@ -792,6 +793,19 @@ export async function patchSessionMetadata(
return updated
}
export async function setLinkedIssue(
sessionId: string,
directory: string | null | undefined,
issue: LinkedIssue,
linked: boolean,
): Promise<Session> {
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
withLinkedIssue(metadata, issue, linked))
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
export async function setContextObligatoryMessage(
sessionId: string,
directory: string | null | undefined,
+9 -3
View File
@@ -3255,14 +3255,20 @@ export function useSessionMessageRecords(
// (e.g. multiple ToolParts) request the same session's messages.
const _ensureMessagesLoading = new Set<string>()
export function useEnsureSessionMessages(sessionID: string, directory?: string) {
/**
* @param enabled Gate for callers that only need a session materialised under
* a specific condition a panel resolving pinned message text, say. Loading a
* whole session is not free, so "something is missing" is not on its own a
* reason to fetch it.
*/
export function useEnsureSessionMessages(sessionID: string, directory?: string, enabled = true) {
const syncDirectory = useSyncDirectory()
const resolvedDirectory = directory ?? syncDirectory
const store = useDirectoryStore(resolvedDirectory)
const requestGenerationRef = React.useRef(0)
React.useEffect(() => {
if (!sessionID) return
if (!sessionID || !enabled) return
const state = store.getState()
// Already loaded into a renderable message/part snapshot — nothing to do.
@@ -3288,7 +3294,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
_ensureMessagesLoading.delete(loadingKey)
}
})()
}, [sessionID, store, resolvedDirectory])
}, [enabled, sessionID, store, resolvedDirectory])
}
const EMPTY_MESSAGES: Message[] = []
const EMPTY_PARTS: Part[] = []
@@ -357,6 +357,11 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
const entry = {
name,
directory: normalizePendingString(req.body?.directory),
// Which surface started the flow. It belongs here rather than in the
// redirect URI: that URI is written into the server's config once and
// deliberately never rewritten, so anything encoded in it would be
// frozen at whatever runtime authorised first.
origin: normalizePendingString(req.body?.origin),
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
};
pendingMcpAuthContextByState.set(state, entry);
@@ -366,6 +371,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
context: {
name: entry.name,
directory: entry.directory,
origin: entry.origin,
},
});
} catch (error) {
@@ -176,6 +176,16 @@ export const createSettingsHelpers = (dependencies) => {
const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim();
result.opencodeBinary = normalized;
}
if (typeof candidate.workStatusPanelEnabled === 'boolean') {
result.workStatusPanelEnabled = candidate.workStatusPanelEnabled;
}
if (Array.isArray(candidate.workStatusHiddenSections)) {
// Ids are validated on the client, which owns the section list; here we
// only guarantee the shape, so a malformed payload cannot land on disk.
result.workStatusHiddenSections = [
...new Set(candidate.workStatusHiddenSections.filter((entry) => typeof entry === 'string' && entry.length > 0)),
];
}
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
}
@@ -1,6 +1,16 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { buildDeferredRestartResponse } from './config-mutation-response.js';
/**
* Matches how OpenCode reads its own boolean env flags: any value other than
* unset, empty, "0" or "false" enables the flag.
*/
const isEnvFlagEnabled = (value) => {
if (typeof value !== 'string') return false;
const normalized = value.trim().toLowerCase();
return normalized.length > 0 && normalized !== '0' && normalized !== 'false';
};
export const registerSkillRoutes = (app, dependencies) => {
const {
fs,
@@ -250,7 +260,24 @@ export const registerSkillRoutes = (app, dependencies) => {
};
});
res.json({ skills: enrichedSkills });
// OpenCode decides which external skill roots it loads from process
// env, and the browser cannot read that. Report the flags alongside the
// scan so the client can narrow its list to what the agent can actually
// invoke.
//
// OpenCode's own skill-list endpoint is not usable for this: on 1.18.14
// it returns only global and builtin skills, omitting the project
// `.agents`/`.claude` skills the agent demonstrably has.
res.json({
skills: enrichedSkills,
externalSkills: {
// `OPENCODE_DISABLE_CLAUDE_CODE` is the broad switch; the specific
// one wins independently — OpenCode ORs them.
claudeDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE)
|| isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS),
allDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_EXTERNAL_SKILLS),
},
});
} catch (error) {
console.error('Failed to list skills:', error);
res.status(500).json({ error: 'Failed to list skills' });