Merge origin/main into deferred OpenCode restart branch

This commit is contained in:
Bohdan Triapitsyn
2026-08-07 10:08:50 +03:00
218 changed files with 12131 additions and 1293 deletions
+49 -16
View File
@@ -32,7 +32,7 @@ import {
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
import { QueuedMessageChips } from './QueuedMessageChips';
import { AutoReviewBanner } from './AutoReviewBanner';
@@ -142,6 +142,10 @@ import { RevertedMessageDock } from './composer/ui/RevertedMessageDock';
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and
// Shiki stacks into the eager startup graph for a dialog opened on demand.
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
const MAX_VISIBLE_COMPOSER_LINES = 8;
/**
* Mobile grows the composer with content instead of offering a fullscreen
@@ -383,6 +387,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
title: '',
content: '',
});
// Mount the lazy preview dialog only after its first open; rendering it
// closed would fetch the ToolOutputDialog chunk (with the @pierre/diffs
// stack) on the draft screen before any preview is requested.
const [attachmentPreviewMounted, setAttachmentPreviewMounted] = React.useState(false);
React.useEffect(() => {
if (attachmentPreview.open) {
setAttachmentPreviewMounted(true);
}
}, [attachmentPreview.open]);
const attachmentCompatibilityRef = React.useRef({
modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`,
modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null,
@@ -936,10 +949,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setPrPickerOpen(true);
}, []);
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : '';
return message.toLowerCase().includes('runtime changed')
? t('chat.chatInput.toast.messageSendFailed')
: message || fallback;
};
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
const inputSnapshot = options?.presetText != null
? {
message: options.presetText,
@@ -1012,7 +1033,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sendMessageOptions = delivery ? { delivery } : undefined;
const sendMessageOptions = capturedTarget
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
: delivery ? { delivery } : undefined;
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
@@ -1058,10 +1081,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (outgoing.isEmpty) return;
// Clear queue and input
if (messageQueueTarget && queuedMessageId) {
removeFromQueue(messageQueueTarget, queuedMessageId);
} else if (messageQueueTarget && hasQueuedMessages) {
clearQueue(messageQueueTarget);
if (capturedTarget && queuedMessageId) {
removeFromQueue(capturedTarget, queuedMessageId);
} else if (capturedTarget && hasQueuedMessages) {
clearQueue(capturedTarget);
}
if (!queuedOnly) {
setMessage('');
@@ -1111,7 +1134,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
}
return;
}
@@ -1143,15 +1166,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
);
scrollToBottom?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : t(command.errorToastKey));
toast.error(getSubmitErrorMessage(error, t(command.errorToastKey)));
}
return;
}
}
const currentSessionDirectory = currentSessionId
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
: currentDirectory;
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
@@ -1258,6 +1279,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
if (normalized.includes('runtime changed')) {
if (allAttachments.length > 0) {
useInputStore.getState().setAttachedFiles(allAttachments);
}
toast.error(t('chat.chatInput.toast.messageSendFailed'));
return;
}
if (allAttachments.length > 0) {
useInputStore.getState().setAttachedFiles(allAttachments);
}
@@ -2770,11 +2799,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
submitting={reviewFlowSubmitting}
onConfirm={handleStartReviewFlow}
/>
<ToolOutputDialog
popup={attachmentPreview}
onOpenChange={handleAttachmentPreviewOpenChange}
isMobile={isMobile}
/>
{attachmentPreviewMounted ? (
<React.Suspense fallback={null}>
<ToolOutputDialog
popup={attachmentPreview}
onOpenChange={handleAttachmentPreviewOpenChange}
isMobile={isMobile}
/>
</React.Suspense>
) : null}
{/* Single always-mounted picker input. It must NOT live inside
ComposerAttachmentControls: that component mounts once per composer
@@ -37,6 +37,7 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
import { setContextObligatoryMessage } from '@/sync/session-actions';
import { isVSCodeRuntime } from '@/lib/desktop';
import { focusChatInput } from './composer/editor/dom';
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
@@ -416,6 +417,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
createdAt: messageCreatedAt,
role: isUser ? 'user' : 'assistant',
}, !isPinnedIntoContext);
// Return focus to the composer so the user can keep typing right
// after adding the message to context (matches the refocus pattern
// used by the model/agent selectors).
requestAnimationFrame(focusChatInput);
} catch (error) {
console.error('[chat-message] failed to update context pin', error);
toast.error(t('chat.messageBody.actions.contextPinFailed'));
@@ -2,7 +2,7 @@ import React from 'react';
import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
@@ -20,7 +20,8 @@ import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
attachMarkdownInteractions,
applyMarkdownCodeBlockWrapState,
@@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { markStartupTrace } from '@/lib/startupTrace';
import {
findLatestUserModelChoice,
shouldPreserveManualModelOverride,
} from '@/lib/messages/userModelChoice';
import { getSyncParts } from '@/sync/sync-refs';
type IconComponent = IconName;
@@ -645,37 +650,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentSessionDirectory ?? undefined,
);
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
// Skip synthetic subagent-completion nudges — restoring from them resets a
// manual model override back to the agent default (issue #2404).
const latestLoadedUserChoice = React.useMemo(() => {
for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & {
model?: { providerID?: string; modelID?: string; variant?: string };
variant?: string;
mode?: string;
};
if (message.role !== 'user') {
continue;
}
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
? message.model.providerID
: undefined;
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
? message.model.modelID
: undefined;
const agent = typeof message.agent === 'string' && message.agent.trim().length > 0
? message.agent
: (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined);
// OpenCode 1.4.0 moved variant from top-level to model.variant.
// Prefer the new location, fall back to the legacy one for older servers.
const variantCandidate = message.model?.variant ?? message.variant;
const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0
? variantCandidate
: undefined;
return { id: message.id, agent, providerID, modelID, variant };
}
return null;
}, [currentSessionMessagesFromSync]);
return findLatestUserModelChoice(
currentSessionMessagesFromSync,
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
);
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
@@ -828,6 +810,25 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
// Manual session override wins over historical / synthetic message metadata.
const savedSessionModel = getSessionModelSelection(currentSessionId);
if (shouldPreserveManualModelOverride({
selectionSource: useConfigStore.getState().selectionSource,
savedSessionModel,
candidate: latestLoadedUserChoice,
})) {
if (savedSessionModel) {
applyModelSelectionWithVariant(
savedSessionModel.providerId,
savedSessionModel.modelId,
resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId),
currentAgentName || undefined,
);
}
latestLoadedUserChoiceRestoreRef.current = restoreKey;
return;
}
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
setAgent(latestLoadedUserChoice.agent);
}
@@ -869,6 +870,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setAgent,
applyModelSelectionWithVariant,
getModelVariantOptions,
getSessionModelSelection,
resolveModelVariantSelection,
saveSessionAgentSelection,
saveAgentModelVariantForSession,
saveSessionModelSelection,
@@ -130,6 +130,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb
return inserted;
}
/**
* True for keydown events CodeMirror re-dispatches after deferring the real
* one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey`
* stamps the replacement event with a `synthetic` expando. These events are
* built from the key name alone, so they carry no modifier keys.
*/
function isDeferredSyntheticEvent(event: KeyboardEvent): boolean {
return Boolean((event as unknown as { synthetic?: boolean }).synthetic);
}
/**
* Compartments are configuration keys, not per-view state, so one set can serve
* every editor. They live at module scope because a kept-alive view outlives
@@ -160,6 +170,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// The real keydown's shift state for the LAST Enter that reached the
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and
// re-dispatches it as a synthetic keydown built from the key name
// alone, dropping every modifier (see `trackRealEnterShift` and the
// `interceptKeys` handler below); this ref is what lets the deferred
// event still tell Shift+Enter from Enter.
const lastRealEnterShiftRef = React.useRef(false);
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
// which would drop focus mid-typing. When a view store is supplied the
@@ -200,7 +218,15 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
any: (_view, event) => {
// A deferred Enter lost its modifiers in the re-dispatch;
// give the caller's policy (Enter vs Shift+Enter) back the
// shift state it saw on the real keydown.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) {
Object.defineProperty(event, 'shiftKey', { value: true });
}
return handlersRef.current.onKeyDown?.(event) ?? false;
},
}];
const view = new EditorView({
@@ -276,6 +302,25 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
viewRef.current = view;
if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real
// keydown is captured without running the keymaps, the browser's
// native newline goes through, and the keymaps then run against a
// synthetic keydown `dispatchKey` builds from the key name alone —
// which has NO modifiers. Recording the real shift state here (a
// plain listener, registered after CodeMirror's own, so it runs
// after the deferral decision but before the deferred dispatch)
// lets the deferred Enter be re-presented with Shift+Enter intact
// instead of arriving as a plain Enter that "sends" where Enter
// sends. Without it, Shift+Enter on iOS/Android submits the
// message instead of inserting a newline. The listener lives on
// the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return;
lastRealEnterShiftRef.current = event.shiftKey;
};
view.contentDOM.addEventListener('keydown', trackRealEnterShift);
return () => {
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its
@@ -0,0 +1,31 @@
import type { Theme } from '@/types/theme';
/**
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
* Apply the result as inline styles on the markdown container so the static
* Shiki theme resolves to the active palette.
*
* Lives apart from `markdownTheme.ts` because that module imports
* `@pierre/diffs` for theme registration; eager consumers of these CSS vars
* (tool output, code blocks) must not pull that stack into the startup graph.
*/
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
const base = theme.colors.syntax.base;
const tokens = theme.colors.syntax.tokens ?? {};
const status = theme.colors.status;
return {
'--md-syntax-foreground': base.foreground,
'--md-syntax-comment': base.comment,
'--md-syntax-string': base.string,
'--md-syntax-number': base.number,
'--md-syntax-keyword': base.keyword,
'--md-syntax-operator': base.operator,
'--md-syntax-function': base.function,
'--md-syntax-type': base.type,
'--md-syntax-variable': base.variable,
'--md-syntax-property': tokens.variableProperty ?? base.variable,
'--md-syntax-inserted': status.success,
'--md-syntax-deleted': status.error,
};
};
@@ -1,5 +1,4 @@
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
import type { Theme } from '@/types/theme';
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
// The static Shiki theme name. Its definition (token colors referencing
@@ -27,29 +26,3 @@ export const ensureMarkdownShikiTheme = (): void => {
Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved),
);
};
/**
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
* Apply the result as inline styles on the markdown container so the static
* Shiki theme resolves to the active palette.
*/
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
const base = theme.colors.syntax.base;
const tokens = theme.colors.syntax.tokens ?? {};
const status = theme.colors.status;
return {
'--md-syntax-foreground': base.foreground,
'--md-syntax-comment': base.comment,
'--md-syntax-string': base.string,
'--md-syntax-number': base.number,
'--md-syntax-keyword': base.keyword,
'--md-syntax-operator': base.operator,
'--md-syntax-function': base.function,
'--md-syntax-type': base.type,
'--md-syntax-variable': base.variable,
'--md-syntax-property': tokens.variableProperty ?? base.variable,
'--md-syntax-inserted': status.success,
'--md-syntax-deleted': status.error,
};
};
@@ -284,16 +284,15 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
const [expanded, setExpanded] = React.useState(true);
const [copiedOutput, setCopiedOutput] = React.useState(false);
const copiedResetTimeoutRef = React.useRef<number | null>(null);
const { t } = useI18n();
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
const hasOutput = output.trim().length > 0;
const clearCopiedResetTimeout = React.useCallback(() => {
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(copiedResetTimeoutRef.current);
@@ -59,6 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
@@ -93,7 +94,7 @@ Why: only navigation tools use the compact static path; all other tools need obs
## Quick map of files in this folder
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
@@ -0,0 +1,19 @@
import React from 'react';
/**
* Plain-text patch rendering used when the rich `@pierre/diffs` preview is
* unavailable: non-diff render modes, preview errors, and while the lazily
* loaded diff preview chunk is still downloading. Lives in its own module so
* `ToolPart` can render it without importing the @pierre/diffs stack.
*/
export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
<pre
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
style={{
backgroundColor: 'var(--syntax-base-background)',
color: 'var(--syntax-base-foreground)',
}}
>
{diff}
</pre>
);
@@ -2,7 +2,6 @@
import React from 'react';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { MessageFilesDisplay } from '../../FileAttachment';
@@ -10,7 +9,6 @@ import { getToolMetadata } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
@@ -24,8 +22,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import {
formatEditOutput,
@@ -42,7 +40,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
import { useDurationTickerNow } from '@/hooks/useDurationTicker';
import {
buildTaskSummaryEntriesFromSession,
normalizeTaskSummaryEntries,
@@ -385,40 +383,6 @@ const getToolDiagnosticSection = (
};
};
const usePierreThemeConfig = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
const availableThemes = React.useMemo(
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
[availableThemes, fallbackLightTheme, lightThemeId],
);
const darkTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
[availableThemes, darkThemeId, fallbackDarkTheme],
);
React.useEffect(() => {
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
}, [darkTheme, lightTheme]);
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
return {
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
};
};
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
@@ -1138,30 +1102,6 @@ const TaskToolSummary: React.FC<{
);
};
interface DiffPreviewProps {
diff: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
diffViewMode: DiffViewMode;
}
const TOOL_DIFF_UNSAFE_CSS = `
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
`;
const TOOL_DIFF_METRICS = {
hunkLineCount: 50,
lineHeight: 24,
diffHeaderHeight: 44,
hunkSeparatorHeight: 24,
spacing: 0,
};
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
...toolDisplayStyles.getCollapsedStyles(),
padding: 0,
@@ -1260,85 +1200,18 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
);
};
const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
<pre
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
style={{
backgroundColor: 'var(--syntax-base-background)',
color: 'var(--syntax-base-foreground)',
}}
>
{diff}
</pre>
// The rich diff preview is the only tool-card piece that needs the
// @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the
// eager chat graph. While the chunk loads, the plain-text patch renders as the
// Suspense fallback, mirroring the preview's own error fallback.
const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview'));
const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => (
<React.Suspense fallback={<PlainDiffFallback diff={diff} />}>
<LazyToolPartDiffPreview diff={diff} diffViewMode={diffViewMode} />
</React.Suspense>
);
class DiffPreviewErrorBoundary extends React.Component<{
resetKey: string;
fallback: React.ReactNode;
children: React.ReactNode;
}, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidUpdate(prevProps: { resetKey: string }) {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
componentDidCatch(error: Error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Tool diff preview failed; rendering raw patch instead.', error);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
const options = React.useMemo(
() => ({
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
diffIndicators: 'none' as const,
hunkSeparators: 'line-info-basic' as const,
lineDiffType: 'none' as const,
disableFileHeader: true,
maxLineDiffLength: 1000,
expansionLineCount: 20,
overflow: 'wrap' as const,
theme: pierreTheme,
themeType: pierreThemeType,
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
}),
[diffViewMode, pierreTheme, pierreThemeType]
);
const fallback = <PlainDiffFallback diff={diff} />;
return (
<div className="typography-code px-1 pb-1 pt-0">
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
<PatchDiff
patch={diff}
metrics={TOOL_DIFF_METRICS}
options={options}
className="block w-full"
/>
</DiffPreviewErrorBoundary>
</div>
);
});
DiffPreview.displayName = 'DiffPreview';
interface ToolExpandedContentProps {
part: ToolPartType;
state: ToolStateUnion;
@@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
const { t } = useI18n();
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
@@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{entry.renderMode === 'diff' ? (
<DiffPreview
diff={entry.patch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>
) : (
@@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
) : isWriteLikeTool && writeLikeInputPatch ? (
<DiffPreview
diff={writeLikeInputPatch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>
) : (
@@ -0,0 +1,140 @@
import React from 'react';
import { PatchDiff } from '@pierre/diffs/react';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { DiffViewMode } from '../DiffViewToggle';
import { PlainDiffFallback } from './PlainDiffFallback';
// Loaded lazily from ToolPart: this is the only part of the tool card that
// needs @pierre/diffs' rendering stack (Shiki core + regex engines), so the
// eager chat graph stays free of it and the chunk downloads on the first
// rendered tool diff.
const TOOL_DIFF_UNSAFE_CSS = `
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
`;
const TOOL_DIFF_METRICS = {
hunkLineCount: 50,
lineHeight: 24,
diffHeaderHeight: 44,
hunkSeparatorHeight: 24,
spacing: 0,
};
const usePierreThemeConfig = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
const availableThemes = React.useMemo(
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
[availableThemes, fallbackLightTheme, lightThemeId],
);
const darkTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
[availableThemes, darkThemeId, fallbackDarkTheme],
);
// Registration is synchronous module state inside @pierre/diffs; rendering
// a PatchDiff with these theme ids requires it to have happened first, so
// register during render rather than in an effect.
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
return {
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
};
};
class DiffPreviewErrorBoundary extends React.Component<{
resetKey: string;
fallback: React.ReactNode;
children: React.ReactNode;
}, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidUpdate(prevProps: { resetKey: string }) {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
componentDidCatch(error: Error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Tool diff preview failed; rendering raw patch instead.', error);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export interface ToolPartDiffPreviewProps {
diff: string;
diffViewMode: DiffViewMode;
}
const ToolPartDiffPreview: React.FC<ToolPartDiffPreviewProps> = React.memo(({ diff, diffViewMode }) => {
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const options = React.useMemo(
() => ({
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
diffIndicators: 'none' as const,
hunkSeparators: 'line-info-basic' as const,
lineDiffType: 'none' as const,
disableFileHeader: true,
maxLineDiffLength: 1000,
expansionLineCount: 20,
overflow: 'wrap' as const,
theme: pierreTheme,
themeType: pierreThemeType,
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
}),
[diffViewMode, pierreTheme, pierreThemeType]
);
const fallback = <PlainDiffFallback diff={diff} />;
return (
<div className="typography-code px-1 pb-1 pt-0">
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
<PatchDiff
patch={diff}
metrics={TOOL_DIFF_METRICS}
options={options}
className="block w-full"
/>
</DiffPreviewErrorBoundary>
</div>
);
});
ToolPartDiffPreview.displayName = 'ToolPartDiffPreview';
export default ToolPartDiffPreview;
@@ -13,7 +13,7 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
// ── Threshold: files smaller than this render without virtualization ──
@@ -1,70 +0,0 @@
import React from 'react';
type Subscriber = (now: number) => void;
type TickerChannel = {
subscribers: Set<Subscriber>;
timerId: number | null;
};
const tickerChannels = new Map<number, TickerChannel>();
const getTickerChannel = (intervalMs: number): TickerChannel => {
const existing = tickerChannels.get(intervalMs);
if (existing) {
return existing;
}
const created: TickerChannel = {
subscribers: new Set<Subscriber>(),
timerId: null,
};
tickerChannels.set(intervalMs, created);
return created;
};
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
const channel = getTickerChannel(intervalMs);
channel.subscribers.add(subscriber);
subscriber(Date.now());
if (channel.timerId === null && typeof window !== 'undefined') {
channel.timerId = window.setInterval(() => {
const now = Date.now();
channel.subscribers.forEach((listener) => {
listener(now);
});
}, intervalMs);
}
return () => {
const tracked = tickerChannels.get(intervalMs);
if (!tracked) {
return;
}
tracked.subscribers.delete(subscriber);
if (tracked.subscribers.size > 0) {
return;
}
if (tracked.timerId !== null && typeof window !== 'undefined') {
window.clearInterval(tracked.timerId);
}
tickerChannels.delete(intervalMs);
};
};
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
return subscribeToTicker(intervalMs, setNow);
}, [active, intervalMs]);
return now;
};
@@ -1,7 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
// Shared static code highlighter backed by the markdown Shiki Web Worker.
@@ -4,13 +4,18 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { DiffViewIcon } from '@/components/icons/DiffIcon';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PullRequestView } from '@/components/views/PullRequestView';
import { TerminalView } from '@/components/views/TerminalView';
import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView';
import { PlanView } from '@/components/views/PlanView';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
// Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView
// or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks
// into the eager startup graph even when no such tab is open.
const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView })));
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
import { ProjectContextPanel } from './RightSidebarTabs';
import { SidebarFilesTree } from './SidebarFilesTree';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -45,6 +50,7 @@ import {
type EmbeddedSessionRuntimeBootstrap,
} from './contextPanelEmbeddedChat';
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import {
type PreviewElementMetadata,
isPreviewElementMetadata,
@@ -2453,6 +2459,13 @@ export const ContextPanel: React.FC = () => {
return;
}
// Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode).
// ghostty-web listens in the bubble phase; stopping capture here would
// swallow the key before the terminal ever sees it (issue #2644).
if (isTerminalEventTarget(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
@@ -2691,13 +2704,13 @@ export const ContextPanel: React.FC = () => {
const activeNonChatContent = activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'git'
? <GitView isActive={isOpen} />
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
: activeTab?.mode === 'pr'
? <PullRequestView />
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
? <PlanView targetPath={activeTab.targetPath} />
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} /></React.Suspense>
: activeTab?.mode === 'preview'
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
: (
@@ -2909,7 +2922,7 @@ export const ContextPanel: React.FC = () => {
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
<div className="h-full min-w-0 flex-1">
{hasOpenEditorFile ? (
<FilesView mode="editor-only" />
<React.Suspense fallback={null}><FilesView mode="editor-only" /></React.Suspense>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
@@ -2975,16 +2988,18 @@ export const ContextPanel: React.FC = () => {
activeTab?.id !== tab.id && 'hidden'
)}
>
<DiffView
hideStackedFileSidebar
stackedDefaultCollapsedAll
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
onDiffScopeChange={handleDiffScopeChange}
targetFilePath={tab.targetPath}
flushContent
/>
<React.Suspense fallback={null}>
<DiffView
hideStackedFileSidebar
stackedDefaultCollapsedAll
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
onDiffScopeChange={handleDiffScopeChange}
targetFilePath={tab.targetPath}
flushContent
/>
</React.Suspense>
</div>
))}
{hasTerminalTab ? (
@@ -2994,7 +3009,9 @@ export const ContextPanel: React.FC = () => {
) : null}
{hasWalkthroughTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
<WalkthroughView directory={effectiveDirectory} />
<React.Suspense fallback={null}>
<WalkthroughView directory={effectiveDirectory} />
</React.Suspense>
</div>
) : null}
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
@@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import {
getVisibleContextRailSurfaces,
sortContextSurfaces,
type ContextSurfaceDescriptor,
} from '@/lib/surfaces/registry';
import {
getEffectiveShortcutPrefix,
isShortcutPrefixHeld,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
const RAIL_TOOLTIP_DELAY_MS = 150;
// Tablet width and up: below this the walkthrough cannot show a stop and its
// code side by side, which is the whole point of the surface.
const WALKTHROUGH_MIN_WIDTH = 768;
// Hold the surface-switch modifier for this long before revealing the order
// number badges on the rail icons.
const RAIL_NUMBER_HOLD_DELAY_MS = 500;
const EMPTY_TABS: never[] = [];
type RailItemProps = {
@@ -44,21 +49,40 @@ type RailItemProps = {
showActivityDot: boolean;
label: string;
description: string;
/** Numeric badge (e.g. the Git changed-files count); takes precedence over the activity dot. */
badgeCount?: number | null;
/** Accessible label that includes the badge count; falls back to `label`. */
badgeAriaLabel?: string | null;
/** Extra tooltip line describing the badge; rendered under the description. */
badgeDescription?: string | null;
orderNumber?: number | null;
showOrderNumber?: boolean;
onSelect: (surface: ContextSurfaceDescriptor) => void;
};
// The badge corner is 16px tall; cap large counts so the pill stays compact
// on the 36px rail button (matching the order-number badge's footprint).
const formatRailBadgeCount = (count: number): string => (count > 99 ? '99+' : String(count));
const ContextPanelRailItem: React.FC<RailItemProps> = ({
surface,
isActive,
showActivityDot,
label,
description,
badgeCount,
badgeAriaLabel,
badgeDescription,
orderNumber,
showOrderNumber,
onSelect,
}) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: surface.id,
});
const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null;
return (
<div
ref={setNodeRef}
@@ -72,7 +96,7 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
{...attributes}
{...listeners}
onClick={() => onSelect(surface)}
aria-label={label}
aria-label={badgeAriaLabel ?? label}
aria-pressed={isActive}
className={cn(
'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors',
@@ -86,7 +110,21 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
) : (
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
)}
{showActivityDot ? (
{showOrderNumber && orderNumber != null ? (
<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"
>
{orderNumber === 10 ? '0' : orderNumber}
</span>
) : 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"
>
{displayBadgeCount}
</span>
) : showActivityDot ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
@@ -98,6 +136,9 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
<div className="flex flex-col gap-0.5">
<span>{label}</span>
<span className="typography-micro text-muted-foreground">{description}</span>
{badgeDescription ? (
<span className="typography-micro text-muted-foreground">{badgeDescription}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
@@ -114,10 +155,86 @@ export const ContextPanelRail: React.FC = () => {
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const { screenWidth } = useDeviceInfo();
const gitStatus = useGitStatus(directoryKey || null);
const surfaceSwitchPrefix = React.useMemo(
() => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides),
[shortcutOverrides],
);
const [revealNumbers, setRevealNumbers] = React.useState(false);
// While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS,
// reveal the order number badges so users can see which digit maps to which
// rail icon. Releasing (or losing focus) dismisses them, and pressing a
// number key while the chord is armed consumes them for this hold — they
// only come back on the next press-and-hold.
React.useEffect(() => {
const held = new Set<string>();
let timer: ReturnType<typeof setTimeout> | null = null;
let consumedWhileHeld = false;
const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9';
const update = () => {
const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held);
if (armed) {
if (!consumedWhileHeld && timer === null) {
timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS);
}
} else {
consumedWhileHeld = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
held.add(e.key.toLowerCase());
if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) {
consumedWhileHeld = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
return;
}
update();
};
const onKeyUp = (e: KeyboardEvent) => {
held.delete(e.key.toLowerCase());
update();
};
const onWindowBlur = () => {
held.clear();
consumedWhileHeld = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setRevealNumbers(false);
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('blur', onWindowBlur);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('blur', onWindowBlur);
if (timer !== null) {
clearTimeout(timer);
}
};
}, [surfaceSwitchPrefix]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
@@ -128,22 +245,13 @@ export const ContextPanelRail: React.FC = () => {
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
const changedFilesCount = gitStatus?.files.length ?? 0;
// Content-driven surfaces are hidden (not disabled) until content exists;
// an existing tab keeps them visible even if the content source went away.
const surfaces = React.useMemo(() => {
return sortContextSurfaces(contextRailOrder).filter((surface) => {
if (surface.id === 'plan' && !planModeEnabled) {
return false;
}
// The walkthrough needs room for a stop list beside real code, and its
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
if (surface.availability === 'has-content') {
return tabs.some((tab) => tab.mode === surface.mode);
}
return true;
return getVisibleContextRailSurfaces({
railOrder: contextRailOrder,
planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth,
tabs,
});
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
@@ -174,17 +282,43 @@ export const ContextPanelRail: React.FC = () => {
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
{surfaces.map((surface) => (
<ContextPanelRailItem
key={surface.id}
surface={surface}
isActive={activeMode === surface.mode}
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
label={t(surface.labelKey)}
description={t(surface.descriptionKey)}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
))}
{surfaces.map((surface, index) => {
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;
const badgeCount = gitChangedCount > 0 ? gitChangedCount : null;
return (
<ContextPanelRailItem
key={surface.id}
surface={surface}
isActive={activeMode === surface.mode}
showActivityDot={false}
label={label}
description={t(surface.descriptionKey)}
badgeCount={badgeCount}
badgeAriaLabel={badgeCount !== null
? t(
badgeCount === 1
? 'contextRail.surface.git.changesCountAriaSingle'
: 'contextRail.surface.git.changesCountAriaPlural',
{ label, count: badgeCount },
)
: null}
badgeDescription={badgeCount !== null
? t(
badgeCount === 1
? 'contextRail.surface.git.changesCountTooltipSingle'
: 'contextRail.surface.git.changesCountTooltipPlural',
{ count: badgeCount },
)
: null}
orderNumber={index + 1}
showOrderNumber={revealNumbers}
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
/>
);
})}
</SortableContext>
</DndContext>
</nav>
@@ -29,14 +29,16 @@ import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { ChatView } from '@/components/views/ChatView';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PlanView } from '@/components/views/PlanView';
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time.
// Other heavy views stay on-demand to reduce initial bundle parse time:
// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the
// startup graph when imported statically.
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
@@ -48,6 +50,17 @@ export const MainLayout: React.FC = () => {
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
// Mount the windowed settings dialog only after its first open: rendering
// the lazy component (even closed) makes React fetch the SettingsView
// chunk graph (CodeMirror editor, vim mode, theme tooling) on startup.
// Once opened it stays mounted so the close animation and state behave as
// before.
const [settingsWindowMounted, setSettingsWindowMounted] = React.useState(false);
React.useEffect(() => {
if (isSettingsDialogOpen) {
setSettingsWindowMounted(true);
}
}, [isSettingsDialogOpen]);
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
@@ -464,12 +477,14 @@ export const MainLayout: React.FC = () => {
</div>
{/* Desktop settings: windowed dialog with blur */}
<React.Suspense fallback={null}>
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
{settingsWindowMounted ? (
<React.Suspense fallback={null}>
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
) : null}
</>
)}
@@ -0,0 +1,187 @@
/**
* Regression guard for https://github.com/openchamber/openchamber/issues/2644
*
* Escape while focus is inside the terminal must reach the PTY (e.g. Vim
* Normal mode). The context panel still closes on Escape when focus is on
* non-terminal panel chrome.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
const mobileWorkspaceDrawerSource = readFileSync(
join(__dirname, '..', '..', '..', 'apps', 'MobileWorkspaceDrawer.tsx'),
'utf-8',
);
describe('issue #2644: Escape in terminal must not close the context panel', () => {
test('the context panel captures Escape at the panel level', () => {
expect(contextPanelSource).toContain('onKeyDownCapture={handlePanelKeyDownCapture}');
});
test('the capture handler skips closing when the event target is inside the terminal', () => {
const start = contextPanelSource.indexOf('const handlePanelKeyDownCapture = React.useCallback(');
expect(start).toBeGreaterThan(-1);
const end = contextPanelSource.indexOf('}, [handleClose]);', start);
expect(end).toBeGreaterThan(start);
const handler = contextPanelSource.slice(start, end);
expect(handler).toContain("event.key !== 'Escape'");
expect(handler).toContain('isTerminalEventTarget(event.target)');
expect(handler).toContain('event.preventDefault()');
expect(handler).toContain('event.stopPropagation()');
expect(handler).toContain('handleClose()');
// Guard must return before preventDefault/stopPropagation so ghostty-web's
// bubble-phase keydown listener can forward Escape to the PTY.
const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)');
const preventIndex = handler.indexOf('event.preventDefault()');
expect(guardIndex).toBeGreaterThan(-1);
expect(preventIndex).toBeGreaterThan(guardIndex);
});
test('ContextPanel imports the shared terminal focus helper', () => {
expect(contextPanelSource).toContain("from '@/lib/terminalFocus'");
expect(contextPanelSource).toContain('isTerminalEventTarget');
});
test('mobile drawer keeps its terminal Escape exception', () => {
const handlerStart = mobileWorkspaceDrawerSource.indexOf("if (event.key === 'Escape'");
expect(handlerStart).toBeGreaterThan(-1);
const handler = mobileWorkspaceDrawerSource.slice(handlerStart, handlerStart + 200);
expect(handler).toContain("tabRef.current !== 'terminal'");
});
});
type Listener = { capture: boolean; onEvent: (event: SimulatedEvent) => void };
type SimulatedEvent = {
type: string;
defaultPrevented: boolean;
propagationStopped: boolean;
target: SimNode;
preventDefault(): void;
stopPropagation(): void;
};
class SimNode {
readonly children: SimNode[] = [];
private listeners: Listener[] = [];
private parent: SimNode | null = null;
addListener(listener: Listener): void {
this.listeners.push(listener);
}
attach(child: SimNode): void {
child.parent = this;
this.children.push(child);
}
dispatch(type: string): SimulatedEvent {
const buildPath = (target: SimNode): SimNode[] => {
const ancestors: SimNode[] = [];
let cursor: SimNode | null = target;
while (cursor !== null) {
ancestors.push(cursor);
cursor = cursor.parent;
}
ancestors.reverse();
return ancestors;
};
const path = buildPath(this);
const event: SimulatedEvent = {
type,
defaultPrevented: false,
propagationStopped: false,
target: this,
preventDefault() {
event.defaultPrevented = true;
},
stopPropagation() {
event.propagationStopped = true;
},
};
for (let i = 0; i < path.length; i += 1) {
if (event.propagationStopped) return event;
for (const listener of path[i].listeners) {
if (!listener.capture) continue;
listener.onEvent(event);
if (event.propagationStopped) return event;
}
}
for (let i = path.length - 1; i >= 0; i -= 1) {
if (event.propagationStopped) return event;
for (const listener of path[i].listeners) {
if (listener.capture) continue;
listener.onEvent(event);
if (event.propagationStopped) return event;
}
}
return event;
}
}
describe('issue #2644: fixed Escape propagation to the terminal', () => {
test('when the panel skips terminal Escape, the terminal bubble handler receives it', () => {
const panel = new SimNode();
const terminalContainer = new SimNode();
panel.attach(terminalContainer);
const calls: string[] = [];
const panelEscapeHandler = (event: SimulatedEvent) => {
// Fixed behavior: do not close / stop when the target is the terminal.
if (event.target === terminalContainer) {
calls.push('panel-capture-skipped');
return;
}
calls.push('panel-capture-closed');
event.preventDefault();
event.stopPropagation();
};
const terminalKeydownHandler = () => {
calls.push('terminal-bubble');
};
panel.addListener({ capture: true, onEvent: panelEscapeHandler });
terminalContainer.addListener({ capture: false, onEvent: terminalKeydownHandler });
const event = terminalContainer.dispatch('keydown');
expect(calls).toEqual(['panel-capture-skipped', 'terminal-bubble']);
expect(event.propagationStopped).toBe(false);
expect(event.defaultPrevented).toBe(false);
});
test('Escape outside the terminal still closes via the capture handler', () => {
const panel = new SimNode();
const headerButton = new SimNode();
const terminalContainer = new SimNode();
panel.attach(headerButton);
panel.attach(terminalContainer);
const calls: string[] = [];
panel.addListener({
capture: true,
onEvent: (event) => {
if (event.target === terminalContainer) return;
calls.push('panel-capture-closed');
event.preventDefault();
event.stopPropagation();
},
});
terminalContainer.addListener({
capture: false,
onEvent: () => calls.push('terminal-bubble'),
});
const event = headerButton.dispatch('keydown');
expect(calls).toEqual(['panel-capture-closed']);
expect(event.propagationStopped).toBe(true);
expect(event.defaultPrevented).toBe(true);
});
});
@@ -436,7 +436,10 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
);
const allowedProviderSet = React.useMemo(() => {
if (!allowedProviderIds || allowedProviderIds.length === 0) return null;
// undefined = no restriction; [] = allow none. Treating empty like
// "unrestricted" would resurface providers without a login in pickers that
// intentionally pass the authenticated-only list.
if (!allowedProviderIds) return null;
return new Set(allowedProviderIds);
}, [allowedProviderIds]);
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { InstanceServiceUrls } from './InstanceServiceUrls';
import {
SettingsSection,
SETTINGS_BRAND_TITLE_CLASS,
@@ -135,6 +136,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
</div>
<InstanceServiceUrls />
</div>
<div className="flex justify-center">
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
</div>
)}
<div className="flex flex-col gap-2 border-b border-border/40 px-4 py-3 @xl:flex-row @xl:items-center @xl:justify-between">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.instanceUrls')}</span>
<InstanceServiceUrls />
</div>
<div className="flex items-center gap-4 px-4 py-4">
<a
href={GITHUB_URL}
@@ -0,0 +1,106 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { openExternalUrl } from '@/lib/url';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
type InstanceServiceInfo = {
port: number | null;
tunnelUrl: string | null;
};
type InstanceService = {
key: string;
label: string;
url: string;
};
/**
* Shows the active instance's service URLs (local server port + tunnel URL,
* when a tunnel is active) as labeled buttons that open the URL in the
* browser. The data comes from `/api/system/info`, which the server derives
* from its own runtime state this is what makes each Git-worktree instance
* distinguishable in the UI without reading terminal output.
*
* The section stays hidden when the endpoint is unavailable or reports no
* port/tunnel (e.g. VS Code runtime), so a failed fetch never renders stale
* or wrong URLs.
*/
export const InstanceServiceUrls: React.FC = () => {
const { t } = useI18n();
const [info, setInfo] = React.useState<InstanceServiceInfo | null>(null);
React.useEffect(() => {
let cancelled = false;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const load = async () => {
try {
const response = await runtimeFetch('/api/system/info', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
const data = await response.json().catch(() => null) as { port?: unknown; tunnelUrl?: unknown } | null;
if (!data || cancelled) return;
const port = typeof data.port === 'number' && Number.isFinite(data.port) && data.port > 0 ? data.port : null;
const tunnelUrl = typeof data.tunnelUrl === 'string' && data.tunnelUrl.trim().length > 0
? data.tunnelUrl.trim()
: null;
setInfo({ port, tunnelUrl });
} catch {
// Best-effort: a failed fetch keeps the section hidden instead of
// showing data we cannot verify.
}
};
void load();
return () => {
cancelled = true;
controller?.abort();
};
}, []);
const services: InstanceService[] = [];
if (info?.port !== null && info?.port !== undefined) {
services.push({
key: 'application',
label: t('settings.openchamber.about.field.applicationUrl'),
url: `http://localhost:${info.port}/`,
});
}
if (info?.tunnelUrl) {
services.push({
key: 'tunnel',
label: t('settings.openchamber.about.field.tunnelUrl'),
url: info.tunnelUrl,
});
}
if (services.length === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
{services.map((service) => (
<Button
key={service.key}
type="button"
variant="outline"
size="sm"
title={service.label}
className="max-w-full gap-1.5 px-2.5"
onClick={() => {
void openExternalUrl(service.url);
}}
>
<Icon name="external-link" className="size-3.5 shrink-0" />
<span className="max-w-64 truncate font-mono typography-micro">{service.url}</span>
</Button>
))}
</div>
);
};
@@ -13,6 +13,7 @@ import {
formatShortcutForDisplay,
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
@@ -49,6 +50,35 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
return normalizeCombo(parts.join('+'));
};
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
// surface"): a bare modifier press is accepted so the prefix can be just the
// primary modifier (default) or a modifier + key chord like `mod+p`.
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) {
parts.push('mod');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
const keyToken = keyToShortcutToken(event.key);
if (!keyToken) {
return null;
}
parts.push(keyToken);
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
};
export const KeyboardShortcutsSettings: React.FC = () => {
const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
@@ -211,10 +241,19 @@ export const KeyboardShortcutsSettings: React.FC = () => {
<div>
{actions.map((action, index) => {
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
const isSurfaceSwitch = action.id === 'switch_context_surface';
const effective = isSurfaceSwitch
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
const displayValue = capturingActionId === action.id
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
: isSurfaceSwitch && !isUnassignedDisplay
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
: formatShortcutForDisplay(displayCombo);
return (
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
@@ -224,7 +263,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
>
<Input
readOnly
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
value={displayValue}
onFocus={() => {
setCapturingActionId(action.id);
setErrorText('');
@@ -243,7 +282,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
return;
}
const combo = keyboardEventToCombo(event);
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
if (!combo) {
return;
}
@@ -152,13 +152,13 @@ export const NotificationSettings: React.FC = () => {
field: 'title' | 'message',
value: string,
) => {
setNotificationTemplates({
...notificationTemplates,
setNotificationTemplates((current) => ({
...current,
[event]: {
...notificationTemplates[event],
...current[event],
[field]: value,
},
});
}));
};
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
@@ -0,0 +1,455 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import {
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { opencodeClient } from '@/lib/opencode/client';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
} from './provider-oauth';
export interface ProviderOAuthMethod {
/** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */
index: number;
label: string;
prompts?: unknown;
}
interface ProviderOAuthMethodsProps {
providerId: string;
methods: ProviderOAuthMethod[];
/** Called once a credential has been stored, so the caller can reload providers. */
onConnected: () => void | Promise<void>;
/** Layout only — the caller owns separation from whatever sits above. */
className?: string;
}
type Flow =
| { phase: 'idle' }
| { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null }
| { phase: 'authorizing'; methodIndex: number }
/** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */
| { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization }
/** `code`: waiting for the user to paste a code out of the browser. */
| { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean }
| { phase: 'failed'; methodIndex: number; message: string };
const IDLE: Flow = { phase: 'idle' };
/**
* OAuth sign-in for a provider's auth methods.
*
* The completion method reported by `authorize` drives everything: `auto`
* chains straight into `callback` and holds it open until the user finishes in
* the browser, `code` collects a pasted code first. See `provider-oauth.ts`.
*
* Only one method can run at a time, and the in-flight callback is aborted when
* this component unmounts. Mount it with `key={providerId}` so switching
* providers starts from a clean flow.
*/
export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
providerId,
methods,
onConnected,
className,
}) => {
const { t } = useI18n();
const [flow, setFlow] = React.useState<Flow>(IDLE);
const [promptValues, setPromptValues] = React.useState<Record<string, string>>({});
const [codeInput, setCodeInput] = React.useState('');
const callbackAbortRef = React.useRef<AbortController | null>(null);
React.useEffect(() => () => callbackAbortRef.current?.abort(), []);
const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex;
const busy = flow.phase === 'authorizing'
|| flow.phase === 'waiting'
|| (flow.phase === 'awaitingCode' && flow.submitting);
const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => {
const result = await copyTextToClipboard(value);
if (result.ok) {
toast.success(t(successKey));
return;
}
console.error('Failed to copy OAuth value:', result.error);
toast.error(t(failureKey));
};
/**
* Runs the blocking half of the flow. Never throws: the caller has already
* handed control to the user, so a failure here is a flow state, not an
* exception to unwind.
*/
const runCallback = async (methodIndex: number, code?: string) => {
const controller = new AbortController();
callbackAbortRef.current?.abort();
callbackAbortRef.current = controller;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.callback(
{
providerID: providerId,
method: methodIndex,
...(code ? { code } : {}),
},
{ signal: controller.signal },
);
if (controller.signal.aborted) {
return;
}
if (result.error) {
throw result.error;
}
setFlow(IDLE);
toast.success(t('settings.providers.page.toast.oauthCompleted'));
await onConnected();
} catch (error) {
if (controller.signal.aborted) {
return;
}
console.error('Failed to complete OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'),
});
} finally {
if (callbackAbortRef.current === controller) {
callbackAbortRef.current = null;
}
}
};
const runAuthorize = async (methodIndex: number, inputs: Record<string, string>) => {
setFlow({ phase: 'authorizing', methodIndex });
let authorization: OAuthAuthorization;
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
...(Object.keys(inputs).length > 0 ? { inputs } : {}),
});
if (result.error) {
throw result.error;
}
const parsed = parseAuthorization(result.data);
if (!parsed) {
setFlow({
phase: 'failed',
methodIndex,
message: t('settings.providers.page.toast.oauthDetailsMissing'),
});
return;
}
authorization = parsed;
} catch (error) {
console.error('Failed to start OAuth flow:', error);
setFlow({
phase: 'failed',
methodIndex,
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'),
});
return;
}
if (authorization.url) {
void openExternalUrl(authorization.url);
}
if (authorization.method === 'code') {
setCodeInput('');
setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false });
return;
}
setFlow({ phase: 'waiting', methodIndex, authorization });
await runCallback(methodIndex);
};
const beginConnect = (method: ProviderOAuthMethod) => {
const prompts = parseAuthPrompts(method.prompts);
if (prompts.length === 0) {
void runAuthorize(method.index, {});
return;
}
setPromptValues(defaultPromptValues(prompts));
setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null });
};
const submitPrompts = () => {
if (flow.phase !== 'prompting') {
return;
}
const unanswered = firstUnansweredPrompt(flow.prompts, promptValues);
if (unanswered) {
setFlow({
...flow,
error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }),
});
return;
}
void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues));
};
const submitCode = () => {
if (flow.phase !== 'awaitingCode') {
return;
}
const code = codeInput.trim();
if (!code) {
return;
}
setFlow({ ...flow, submitting: true });
void runCallback(flow.methodIndex, code);
};
/**
* Stops tracking the attempt. Upstream keeps its pending authorization until
* a new `authorize` replaces it, so reconnecting is always safe.
*/
const cancel = () => {
callbackAbortRef.current?.abort();
callbackAbortRef.current = null;
setFlow(IDLE);
};
const renderPrompt = (prompt: AuthPrompt) => {
const value = promptValues[prompt.key] ?? '';
const setValue = (next: string) =>
setPromptValues((prev) => ({ ...prev, [prompt.key]: next }));
return (
<div key={prompt.key} className="space-y-1.5">
<label className="typography-ui-label text-foreground">{prompt.message}</label>
{prompt.type === 'select' ? (
<Select value={value} onValueChange={setValue}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue>
{(current) => prompt.options.find((option) => option.value === current)?.label ?? null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{prompt.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.hint ? `${option.label} · ${option.hint}` : option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={prompt.placeholder}
className="max-w-[24rem] text-xs"
/>
)}
</div>
);
};
const renderAuthorizationDetails = (authorization: OAuthAuthorization) => (
<>
{authorization.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{authorization.instructions}
</p>
)}
{authorization.userCode && (
<div className="flex items-center gap-2">
<Input
value={authorization.userCode}
readOnly
aria-label={t('settings.providers.page.auth.oauth.deviceCodeLabel')}
className="font-mono text-center tracking-widest"
/>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => void copy(
authorization.userCode ?? '',
'settings.providers.page.toast.deviceCodeCopied',
'settings.providers.page.toast.deviceCodeCopyFailed',
)}
>
{t('settings.providers.page.actions.copyCode')}
</Button>
</div>
)}
{authorization.url && (
<div className="flex items-center gap-2">
<Input
value={authorization.url}
readOnly
aria-label={t('settings.providers.page.auth.oauth.linkLabel')}
className="text-xs text-muted-foreground"
/>
<div className="flex gap-1 shrink-0">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void openExternalUrl(authorization.url ?? '')}
>
{t('settings.providers.page.actions.open')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void copy(
authorization.url ?? '',
'settings.providers.page.toast.oauthLinkCopied',
'settings.providers.page.toast.oauthLinkCopyFailed',
)}
>
{t('settings.providers.page.actions.copy')}
</Button>
</div>
</div>
)}
</>
);
return (
<div className={cn('space-y-4', className)}>
{methods.map((method) => {
const isActive = activeIndex === method.index;
return (
<div key={method.index} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label text-foreground">{method.label}</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
onClick={() => beginConnect(method)}
disabled={busy}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{isActive && flow.phase === 'prompting' && (
<div className="space-y-3">
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)}
{flow.error && (
<p className="typography-meta text-[var(--status-error)]">{flow.error}</p>
)}
<div className="flex items-center gap-2">
<Button size="xs" className="!font-normal" onClick={submitPrompts}>
{t('settings.providers.page.actions.continue')}
</Button>
<Button variant="ghost" size="xs" className="!font-normal" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'authorizing' && (
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.starting')}
</p>
)}
{isActive && flow.phase === 'waiting' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<div className="flex items-center justify-between gap-2">
<p className="typography-meta text-muted-foreground flex items-center gap-2">
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
{t('settings.providers.page.auth.oauth.waiting')}
</p>
<Button variant="ghost" size="xs" className="!font-normal shrink-0" onClick={cancel}>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.waitingHint')}
</p>
</div>
)}
{isActive && flow.phase === 'awaitingCode' && (
<div className="space-y-3">
{renderAuthorizationDetails(flow.authorization)}
<p className="typography-meta text-muted-foreground">
{t('settings.providers.page.auth.oauth.codeHint')}
</p>
<div className="flex items-center gap-2">
<Input
value={codeInput}
onChange={(event) => setCodeInput(event.target.value)}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
disabled={flow.submitting}
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={submitCode}
disabled={flow.submitting || codeInput.trim().length === 0}
>
{flow.submitting
? t('settings.providers.page.actions.saving')
: t('settings.providers.page.actions.complete')}
</Button>
<Button
variant="ghost"
size="xs"
className="!font-normal shrink-0"
onClick={cancel}
disabled={flow.submitting}
>
{t('settings.providers.page.actions.cancel')}
</Button>
</div>
</div>
)}
{isActive && flow.phase === 'failed' && (
<div className="space-y-2">
<p className="typography-meta text-[var(--status-error)]">{flow.message}</p>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => beginConnect(method)}
>
{t('settings.providers.page.actions.tryAgain')}
</Button>
</div>
)}
</div>
);
})}
</div>
);
};
@@ -19,8 +19,6 @@ import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -31,8 +29,10 @@ import {
parseAuthPayload,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
} from './providerAuth';
import { CustomProviderForm } from './CustomProviderForm';
import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods';
import {
buildAuthSetRequest,
buildProviderUpsertRequest,
@@ -85,6 +85,16 @@ interface ProviderSources {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const toOAuthMethods = (
entries: OAuthAuthMethodEntry[],
fallbackLabel: (index: number) => string,
): ProviderOAuthMethod[] =>
entries.map(({ method, methodIndex }) => ({
index: methodIndex,
label: method.label || method.name || fallbackLabel(methodIndex),
prompts: method.prompts,
}));
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
if (typeof entry === 'string') {
return { id: entry };
@@ -147,9 +157,6 @@ export const ProvidersPage: React.FC = () => {
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
const [modelQuery, setModelQuery] = React.useState('');
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
const [availableLoading, setAvailableLoading] = React.useState(false);
const [availableError, setAvailableError] = React.useState<string | null>(null);
@@ -181,7 +188,8 @@ export const ProvidersPage: React.FC = () => {
React.useEffect(() => {
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
// them loaded for the active provider view so OAuth-only plugins never fall
// back to an API key form merely because methods were never fetched.
// back to an API key form merely because methods were never fetched, and so
// an already-listed provider can still offer re-authentication.
if (!selectedProviderId) {
return;
}
@@ -458,117 +466,13 @@ export const ProvidersPage: React.FC = () => {
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
const oauthMethodFallbackLabel = (index: number) =>
t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
try {
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
providerID: providerId,
method: methodIndex,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
}
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
const nestedData = payloadRecord.data;
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
undefined;
const instructions =
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
undefined;
const userCode =
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
undefined;
if (!urlCandidate && !instructions && !userCode) {
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
}
const detailsKey = `${providerId}:${methodIndex}`;
setOauthDetails((prev) => ({
...prev,
[detailsKey]: {
url: urlCandidate,
instructions,
userCode,
},
}));
if (urlCandidate) {
void openExternalUrl(urlCandidate);
}
setPendingOAuth({ providerId, methodIndex });
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
} catch (error) {
console.error('Failed to start OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
const codeKey = `${providerId}:${methodIndex}`;
const code = oauthCodes[codeKey]?.trim();
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
try {
const requestBody: { method: number; code?: string } = { method: methodIndex };
if (code) {
requestBody.code = code;
}
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
providerID: providerId,
method: requestBody.method,
code: requestBody.code,
});
if (result.error) {
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
setPendingOAuth(null);
recordDeferredOpenCodeRestart('providers', { id: providerId });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to complete OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleCopyOAuthLink = async (url: string) => {
const result = await copyTextToClipboard(url);
if (result.ok) {
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
return;
}
console.error('Failed to copy OAuth link:', result.error);
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
};
const handleCopyOAuthCode = async (code: string) => {
const result = await copyTextToClipboard(code);
if (result.ok) {
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
return;
}
console.error('Failed to copy device code:', result.error);
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
const handleOAuthConnected = (providerId: string) => {
setShowAuthPanel(false);
recordDeferredOpenCodeRestart('providers', { id: providerId });
setSelectedProvider(providerId);
};
const handleDisconnectProvider = async (providerId: string) => {
@@ -779,7 +683,10 @@ export const ProvidersPage: React.FC = () => {
<>
{(() => {
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
const candidateOAuthMethods = toOAuthMethods(
getOAuthAuthMethods(candidateAuthMethods),
oauthMethodFallbackLabel,
);
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
return (
@@ -816,85 +723,13 @@ export const ProvidersPage: React.FC = () => {
) : null}
{candidateOAuthMethods.length > 0 ? (
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
{candidateOAuthMethods.map(({ method, methodIndex }) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
const codeKey = `${candidateProviderId}:${methodIndex}`;
const isPending =
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
return (
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
<ProviderOAuthMethods
key={candidateProviderId}
providerId={candidateProviderId}
methods={candidateOAuthMethods}
onConnected={() => handleOAuthConnected(candidateProviderId)}
className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
) : null}
</>
);
@@ -921,7 +756,10 @@ export const ProvidersPage: React.FC = () => {
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
const oauthAuthMethods = toOAuthMethods(
getOAuthAuthMethods(providerAuthMethods),
oauthMethodFallbackLabel,
);
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
const sourcesLoaded = Boolean(selectedSources);
const isEditableCustomProvider = sourcesLoaded
@@ -1064,85 +902,13 @@ export const ProvidersPage: React.FC = () => {
) : null}
{oauthAuthMethods.length > 0 && (
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
{oauthAuthMethods.map(({ method, methodIndex }) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
const codeKey = `${selectedProvider.id}:${methodIndex}`;
const isPending =
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
return (
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, methodIndex)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
<ProviderOAuthMethods
key={selectedProvider.id}
providerId={selectedProvider.id}
methods={oauthAuthMethods}
onConnected={() => handleOAuthConnected(selectedProvider.id)}
className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}
/>
)}
</div>
)}
@@ -0,0 +1,236 @@
import { describe, expect, test } from 'bun:test';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ label: 'GitHub.com', value: 'github.com', hint: 'Public' },
{ label: 'GitHub Enterprise', value: 'enterprise' },
],
},
{
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
},
];
describe('parseAuthPrompts', () => {
test('parses select and conditional text prompts', () => {
const prompts = parseAuthPrompts(copilotPrompts);
expect(prompts).toHaveLength(2);
expect(prompts[0]).toEqual({
type: 'select',
key: 'deploymentType',
message: 'Select GitHub deployment type',
options: [
{ value: 'github.com', label: 'GitHub.com', hint: 'Public' },
{ value: 'enterprise', label: 'GitHub Enterprise' },
],
});
expect(prompts[1]).toEqual({
type: 'text',
key: 'enterpriseUrl',
message: 'Enter your GitHub Enterprise URL or domain',
options: [],
placeholder: 'company.ghe.com',
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
});
});
test('returns an empty list for a method without prompts', () => {
expect(parseAuthPrompts(undefined)).toEqual([]);
expect(parseAuthPrompts(null)).toEqual([]);
expect(parseAuthPrompts({})).toEqual([]);
});
test('drops entries that could never be answered', () => {
const prompts = parseAuthPrompts([
{ type: 'text', message: 'no key' },
{ type: 'select', key: 'empty', message: 'no options', options: [] },
{ type: 'text', key: 'keep', message: 'keep me' },
]);
expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']);
});
test('falls back to the key when a message is missing', () => {
expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token');
});
test('ignores a malformed when condition instead of hiding the prompt', () => {
const [prompt] = parseAuthPrompts([
{ type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } },
]);
expect(prompt.when).toBe(undefined);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt visibility', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('hides a conditional prompt until its branch is selected', () => {
expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key))
.toEqual(['deploymentType']);
expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key))
.toEqual(['deploymentType', 'enterpriseUrl']);
});
test('supports neq conditions', () => {
const prompt: AuthPrompt = {
type: 'text',
key: 'custom',
message: 'Custom',
options: [],
when: { key: 'mode', op: 'neq', value: 'default' },
};
expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false);
expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true);
expect(isPromptVisible(prompt, {})).toBe(true);
});
});
describe('prompt answers', () => {
const prompts = parseAuthPrompts(copilotPrompts);
test('preselects the first select option so the form starts answerable', () => {
expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' });
expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull();
});
test('reports the hidden-then-revealed field as unanswered', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' };
expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl');
});
test('omits answers whose prompt is no longer visible', () => {
const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' };
expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' });
});
test('trims submitted answers', () => {
const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' };
expect(collectPromptInputs(prompts, values)).toEqual({
deploymentType: 'enterprise',
enterpriseUrl: 'company.ghe.com',
});
});
});
describe('parseAuthorization', () => {
test('reads a device-code authorization and recovers the code from instructions', () => {
const authorization = parseAuthorization({
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
method: 'auto',
});
expect(authorization).toEqual({
method: 'auto',
url: 'https://github.com/login/device',
instructions: 'Enter code: 1A2B-3C4D',
userCode: '1A2B-3C4D',
});
});
test('keeps an explicitly reported code over the instructions match', () => {
expect(parseAuthorization({
url: 'https://example.com',
instructions: 'Enter code: AAAA-BBBB',
user_code: 'ZZZZ-9999',
method: 'auto',
})?.userCode).toBe('ZZZZ-9999');
});
test('preserves the code method', () => {
expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code');
});
test('treats a missing or unknown method as auto', () => {
expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto');
expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto');
});
test('unwraps a nested data envelope', () => {
expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({
method: 'code',
url: 'https://example.com',
});
});
test('accepts device-authorization field names', () => {
expect(parseAuthorization({
verification_uri_complete: 'https://example.com/activate?code=1',
message: 'Open the link',
})).toEqual({
method: 'auto',
url: 'https://example.com/activate?code=1',
instructions: 'Open the link',
});
});
test('returns null when nothing is actionable', () => {
expect(parseAuthorization(null)).toBeNull();
expect(parseAuthorization({})).toBeNull();
expect(parseAuthorization({ method: 'auto' })).toBeNull();
});
});
describe('describeOAuthError', () => {
const t: ProviderOAuthTranslator = (key) => key;
const fallback = 'settings.providers.page.toast.oauthCompleteFailed';
/** Names come from OpenCode's ProviderAuthApiError schema. */
test('maps each provider auth error name to its own message', () => {
expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.sessionExpired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.codeRequired');
expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.declined');
});
test('surfaces the plugin-authored validation message verbatim', () => {
const error = {
name: 'ProviderAuthValidationFailed',
data: { field: 'enterpriseUrl', message: 'URL or domain is required' },
};
expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required');
});
test('falls back when a validation failure carries no message', () => {
expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback))
.toBe('settings.providers.page.auth.oauth.error.invalidInput');
});
test('falls back for unknown, empty, and non-object errors', () => {
expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback);
expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback);
expect(describeOAuthError(undefined, t, fallback)).toBe(fallback);
});
});
@@ -0,0 +1,244 @@
/**
* Provider OAuth flow helpers.
*
* `POST /provider/{id}/oauth/authorize` answers with the completion method that
* decides what the client has to do next:
*
* - `auto` the client must call `oauth/callback` right away and hold that
* request open. Upstream blocks inside it (device-code polling, or waiting on
* a loopback redirect) until the user finishes signing in, and only then
* persists the credential. Nothing is stored if the client never calls it.
* - `code` the user copies a code out of the browser and hands it to
* `oauth/callback`.
*
* Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported
* for third-party auth plugins that still return it.
*/
import type { I18nKey, I18nParams } from '@/lib/i18n';
export type OAuthCompletionMethod = 'auto' | 'code';
export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string;
export interface OAuthAuthorization {
method: OAuthCompletionMethod;
url?: string;
instructions?: string;
/** Device code surfaced separately so it can be copied on its own. */
userCode?: string;
}
export interface AuthPromptOption {
label: string;
value: string;
hint?: string;
}
export interface AuthPromptCondition {
key: string;
op: 'eq' | 'neq';
value: string;
}
export interface AuthPrompt {
type: 'text' | 'select';
key: string;
message: string;
placeholder?: string;
options: AuthPromptOption[];
when?: AuthPromptCondition;
}
/**
* Device codes are only carried inside the human-readable instructions
* (`Enter code: ABCD-1234`), so they are recovered by shape.
*/
const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const asText = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined;
const parsePromptOptions = (value: unknown): AuthPromptOption[] => {
if (!Array.isArray(value)) {
return [];
}
const options: AuthPromptOption[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const optionValue = asText(entry.value);
if (optionValue === undefined) {
continue;
}
options.push({
value: optionValue,
label: asText(entry.label) ?? optionValue,
...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}),
});
}
return options;
};
const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => {
if (!isRecord(value)) {
return undefined;
}
const key = asText(value.key);
const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined;
if (!key || !op || typeof value.value !== 'string') {
return undefined;
}
return { key, op, value: value.value };
};
/** Parses the `prompts` an auth method wants answered before `authorize`. */
export const parseAuthPrompts = (value: unknown): AuthPrompt[] => {
if (!Array.isArray(value)) {
return [];
}
const prompts: AuthPrompt[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const key = asText(entry.key);
if (!key) {
continue;
}
const type = entry.type === 'select' ? 'select' : 'text';
const options = type === 'select' ? parsePromptOptions(entry.options) : [];
// A select with no usable option can never be answered; skipping it would
// silently drop a required input, so treat the whole method as unusable.
if (type === 'select' && options.length === 0) {
continue;
}
const when = parsePromptCondition(entry.when);
prompts.push({
type,
key,
message: asText(entry.message) ?? key,
options,
...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}),
...(when ? { when } : {}),
});
}
return prompts;
};
/** True when a prompt's `when` condition is satisfied by the answers so far. */
export const isPromptVisible = (prompt: AuthPrompt, values: Record<string, string>): boolean => {
if (!prompt.when) {
return true;
}
const current = values[prompt.when.key] ?? '';
return prompt.when.op === 'eq'
? current === prompt.when.value
: current !== prompt.when.value;
};
export const visiblePrompts = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values));
/** Selects preselect their first option so the form always starts answerable. */
export const defaultPromptValues = (prompts: AuthPrompt[]): Record<string, string> => {
const values: Record<string, string> = {};
for (const prompt of prompts) {
values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : '';
}
return values;
};
/** First visible prompt still left blank, or `null` when the form is complete. */
export const firstUnansweredPrompt = (
prompts: AuthPrompt[],
values: Record<string, string>,
): AuthPrompt | null =>
visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null;
/**
* Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a
* stale answer from a since-changed branch is never sent upstream.
*/
export const collectPromptInputs = (
prompts: AuthPrompt[],
values: Record<string, string>,
): Record<string, string> => {
const inputs: Record<string, string> = {};
for (const prompt of visiblePrompts(prompts, values)) {
inputs[prompt.key] = (values[prompt.key] ?? '').trim();
}
return inputs;
};
/**
* Normalizes an `authorize` response.
*
* Anything that is not explicitly `code` is treated as `auto`: `auto` only
* means "call back and wait", which is also the safe reading of an unknown
* method, whereas guessing `code` would strand the user at a paste field no
* provider can fill.
*
* Returns `null` when the response carries nothing the user can act on.
*/
export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => {
const outer: Record<string, unknown> = isRecord(payload) ? payload : {};
const record: Record<string, unknown> = isRecord(outer.data) ? outer.data : outer;
const url =
asText(record.url)
?? asText(record.verification_uri_complete)
?? asText(record.verification_uri);
const instructions = asText(record.instructions) ?? asText(record.message);
if (!url && !instructions) {
return null;
}
const userCode =
asText(record.user_code)
?? asText(record.userCode)
?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined);
return {
method: record.method === 'code' ? 'code' : 'auto',
...(url ? { url } : {}),
...(instructions ? { instructions } : {}),
...(userCode ? { userCode } : {}),
};
};
/**
* Renders a `ProviderAuthApiError` as user-facing copy.
*
* Validation failures carry a message authored by the auth plugin (a field
* rule such as "URL or domain is required"); it is shown verbatim because only
* the plugin knows which input was rejected.
*/
export const describeOAuthError = (
error: unknown,
t: ProviderOAuthTranslator,
fallbackKey: I18nKey,
): string => {
const record: Record<string, unknown> = isRecord(error) ? error : {};
const data: Record<string, unknown> = isRecord(record.data) ? record.data : {};
switch (record.name) {
case 'ProviderAuthOauthMissing':
return t('settings.providers.page.auth.oauth.error.sessionExpired');
case 'ProviderAuthOauthCodeMissing':
return t('settings.providers.page.auth.oauth.error.codeRequired');
case 'ProviderAuthOauthCallbackFailed':
return t('settings.providers.page.auth.oauth.error.declined');
case 'ProviderAuthValidationFailed':
return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput');
default:
return t(fallbackKey);
}
};
@@ -5,6 +5,8 @@ export interface AuthMethod {
description?: string;
help?: string;
method?: number;
/** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */
prompts?: unknown;
[key: string]: unknown;
}
@@ -22,6 +22,10 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
import {
isFilesystemError,
type FilesystemErrorReason,
} from '@/lib/api/files-errors';
interface DirectoryExplorerDialogProps {
open: boolean;
@@ -148,7 +152,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile } = useDeviceInfo();
const inputRef = React.useRef<HTMLInputElement>(null);
const addButtonRef = React.useRef<HTMLButtonElement>(null);
@@ -158,6 +162,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
const [browseErrorReason, setBrowseErrorReason] = React.useState<FilesystemErrorReason | null>(null);
const [browseReloadKey, setBrowseReloadKey] = React.useState(0);
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const [isConfirming, setIsConfirming] = React.useState(false);
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
@@ -250,16 +256,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
React.useEffect(() => {
if (!open || !browseDirectoryAbsolutePath) {
setEntries([]);
setBrowseErrorReason(null);
return;
}
let cancelled = false;
setIsLoading(true);
setIsBrowseDirectoryMissing(false);
setBrowseErrorReason(null);
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
.then((result) => {
if (cancelled) return;
setIsBrowseDirectoryMissing(false);
setBrowseErrorReason(null);
const nextEntries = result
.filter((entry) => entry.isDirectory)
.map((entry) => ({
@@ -269,10 +278,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
.sort((left, right) => left.name.localeCompare(right.name));
setEntries(nextEntries);
})
.catch(() => {
.catch((error) => {
if (!cancelled) {
setEntries([]);
setIsBrowseDirectoryMissing(true);
const reason = isFilesystemError(error) ? error.reason : 'unknown';
setBrowseErrorReason(reason);
setIsBrowseDirectoryMissing(reason === 'not-found');
}
})
.finally(() => {
@@ -282,7 +293,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
return () => {
cancelled = true;
};
}, [browseDirectoryAbsolutePath, open]);
}, [browseDirectoryAbsolutePath, browseReloadKey, open]);
const filteredEntries = React.useMemo(() => {
const lowerFilter = browseFilterQuery.toLowerCase();
@@ -327,12 +338,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const shouldCreateTarget = Boolean(
targetPath
&& !isAlreadyAdded
&& (browseErrorReason === null || browseErrorReason === 'not-found')
&& (
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
)
);
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
const canAddProject = !isConfirming
&& !isOpeningFinder
&& !isAlreadyAdded
&& browseErrorReason !== 'os-permission'
&& browseErrorReason !== 'invalid-response'
&& browseErrorReason !== 'unknown'
&& Boolean(targetPath);
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
const highlightedRow = rows[highlightedIndex] ?? null;
const hasHighlightedBrowseItem = Boolean(
@@ -459,7 +477,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}, [browseToDisplayPath, browseToEntry]);
const handleOpenInFinder = React.useCallback(async () => {
if (!isDesktop || isOpeningFinder) return;
if (!canRequestAccess || isOpeningFinder) return;
setIsOpeningFinder(true);
try {
const result = await requestAccess(targetPath);
@@ -488,7 +506,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
} finally {
setIsOpeningFinder(false);
}
}, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
}, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
@@ -596,6 +614,24 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<div className="py-10 text-center typography-ui-label text-muted-foreground">
{t('directoryExplorerDialog.browse.loading')}
</div>
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<div className="typography-ui-label text-status-error">
{browseErrorReason === 'os-permission'
? t('directoryExplorerDialog.browse.permissionDenied')
: t('directoryExplorerDialog.browse.loadFailed')}
</div>
<div className="flex items-center gap-2">
{browseErrorReason === 'os-permission' && canRequestAccess ? (
<Button size="xs" onClick={() => void handleOpenInFinder()} disabled={isOpeningFinder}>
{t('directoryExplorerDialog.browse.grantAccess')}
</Button>
) : null}
<Button variant="outline" size="xs" onClick={() => setBrowseReloadKey((key) => key + 1)}>
{t('directoryExplorerDialog.browse.retry')}
</Button>
</div>
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center typography-ui-label text-muted-foreground">
{t('directoryExplorerDialog.browse.empty')}
@@ -688,7 +724,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
<>
{!isMobile ? footerHints : null}
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
{isDesktop ? (
{canRequestAccess ? (
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
</Button>
@@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
<div className="typography-micro truncate text-muted-foreground">
{formatSchedule(task, t)}
</div>
{task.loopFile ? (
<div
className="typography-micro truncate text-muted-foreground/70"
title={task.loopFile}
>
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() {
className={cn(
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
task.enabled ? 'text-foreground' : 'text-muted-foreground',
isBusy && 'cursor-not-allowed opacity-50',
(isBusy || task.loopFile) && 'cursor-not-allowed opacity-50',
)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled')
: undefined}
>
<Checkbox
checked={task.enabled}
@@ -534,7 +545,7 @@ export function ScheduledTasksDialog() {
ariaLabel={task.enabled
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
disabled={isBusy}
disabled={isBusy || Boolean(task.loopFile)}
/>
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
</label>
@@ -555,7 +566,10 @@ export function ScheduledTasksDialog() {
setEditorTask(task);
setEditorOpen(true);
}}
disabled={isBusy}
disabled={isBusy || Boolean(task.loopFile)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
: undefined}
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
>
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
@@ -564,7 +578,10 @@ export function ScheduledTasksDialog() {
variant="destructive"
size="sm"
onClick={() => void handleDeleteTask(task)}
disabled={isBusy}
disabled={isBusy || Boolean(task.loopFile)}
title={task.loopFile
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
: undefined}
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
>
<Icon name="delete-bin" className="h-4 w-4" />
@@ -0,0 +1,58 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useDurationTickerNow } from '@/hooks/useDurationTicker';
import {
useSessionActivityStartedAt,
useSessionSettledDurationMs,
} from '@/sync/session-activity-timing';
import { formatSessionActivityDuration } from './sessionActivityDurationFormat';
/** One update per second: the readout is the animation, at 1 fps instead of 60. */
const TICK_MS = 1000;
/**
* Elapsed time of a session's current turn, or of the turn that just finished.
* Colored to match the row's status dot in each state, so the pair reads as one
* indicator rather than two.
*
* Deliberately a leaf. The tick re-renders this span alone rather than the
* session row around it, which is what makes a live counter cheaper than the
* spinner it replaced that spinner repainted a composited layer per row every
* frame for as long as the session ran.
*/
export const SessionActivityDuration: React.FC<{
sessionId: string;
/** Turn still running (`busy` or `retry`); false renders the settled total. */
running: boolean;
className?: string;
}> = ({ sessionId, running, className }) => {
const { t } = useI18n();
const startedAt = useSessionActivityStartedAt(sessionId);
const settledMs = useSessionSettledDurationMs(sessionId);
const now = useDurationTickerNow(running, TICK_MS);
const durationMs = running ? Math.max(0, now - (startedAt ?? now)) : settledMs;
if (durationMs === undefined) return null;
const label = formatSessionActivityDuration(durationMs, t);
const description = running
? t('sessions.sidebar.session.status.activeFor', { duration: label })
: t('sessions.sidebar.session.status.lastTurnDuration', { duration: label });
return (
<span
className={cn(
'shrink-0 tabular-nums',
// The readout wears its dot's color, so the row reads as one signal:
// primary while the turn runs, info once it is waiting to be read.
running ? 'text-primary' : 'text-[var(--status-info)]',
className,
)}
aria-label={description}
title={description}
>
{label}
</span>
);
};
@@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { Icon } from '@/components/icon/Icon';
import { TooltipProvider } from '@/components/ui/tooltip';
import { NewWorktreeDialog } from './NewWorktreeDialog';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
@@ -243,12 +242,14 @@ const ProjectAggregateStatusIndicator: React.FC<{ directories: Array<string | nu
return false;
}, [directorySet]));
// Aggregate header: dot only. A collapsed project can hold several running
// turns, so a single elapsed counter would have nothing to count.
if (hasBusySession) {
return (
<Icon
name="loader-4"
className="h-3 w-3 animate-spin text-primary"
<span
className="h-1.5 w-1.5 rounded-full bg-primary"
aria-label={t('sessions.sidebar.session.status.active')}
title={t('sessions.sidebar.session.status.active')}
/>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import { dict as enDict } from '@/lib/i18n/messages/en';
import { formatMessage, type I18nKey, type I18nParams } from '@/lib/i18n';
import { formatSessionActivityDuration } from './sessionActivityDurationFormat';
const t = (key: I18nKey, params?: I18nParams): string => formatMessage(enDict, key, params);
const format = (ms: number): string => formatSessionActivityDuration(ms, t);
describe('formatSessionActivityDuration', () => {
test('renders seconds below a minute', () => {
expect(format(0)).toBe('0s');
expect(format(999)).toBe('0s');
expect(format(7_400)).toBe('7s');
expect(format(59_999)).toBe('59s');
});
test('renders minutes and seconds below an hour', () => {
expect(format(60_000)).toBe('1m 0s');
expect(format(83_000)).toBe('1m 23s');
expect(format(59 * 60_000 + 59_000)).toBe('59m 59s');
});
test('drops seconds past an hour so the label stays narrow', () => {
expect(format(3_600_000)).toBe('1h 0m');
expect(format(3_600_000 + 2 * 60_000 + 33_000)).toBe('1h 2m');
expect(format(25 * 3_600_000)).toBe('25h 0m');
});
test('clamps a negative duration rather than rendering a negative count', () => {
expect(format(-5_000)).toBe('0s');
});
});
@@ -0,0 +1,32 @@
import type { I18nKey, I18nParams } from '@/lib/i18n';
type Translate = (key: I18nKey, params?: I18nParams) => string;
const SECOND_MS = 1000;
const MINUTE_MS = 60 * SECOND_MS;
const HOUR_MS = 60 * MINUTE_MS;
/**
* Compact turn duration for a session row: `7s`, `1m 23s`, `1h 2m`.
*
* Seconds are dropped past an hour so the label cannot outgrow the row's
* metadata slot, and the unit suffixes are translated rather than concatenated
* so locales that place or spell them differently stay correct.
*/
export const formatSessionActivityDuration = (durationMs: number, t: Translate): string => {
const total = Math.max(0, durationMs);
if (total < MINUTE_MS) {
return t('common.duration.secondsCompact', { seconds: Math.floor(total / SECOND_MS) });
}
if (total < HOUR_MS) {
return t('common.duration.minutesSecondsCompact', {
minutes: Math.floor(total / MINUTE_MS),
seconds: Math.floor((total % MINUTE_MS) / SECOND_MS),
});
}
return t('common.duration.hoursMinutesCompact', {
hours: Math.floor(total / HOUR_MS),
minutes: Math.floor((total % HOUR_MS) / MINUTE_MS),
});
};
@@ -6,6 +6,7 @@
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state.
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
@@ -31,7 +32,8 @@
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
@@ -76,4 +78,5 @@
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
@@ -9,6 +9,7 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -32,6 +33,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
import {
getSessionNodesActivityState,
@@ -348,18 +350,57 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const groupPrSummary = usePrVisualSummary(groupPrKey);
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
const childStores = useChildStoreManager();
const bootstrapDirectory = normalizePath(group.directory ?? null);
const bootstrapState = React.useSyncExternalStore(
const bootstrapDirectories = React.useMemo(() => {
const directories = group.folderScopes?.map((scope) => normalizePath(scope.directory))
?? [normalizePath(group.directory ?? null)];
return [...new Set(directories.filter((directory): directory is string => Boolean(directory)))];
}, [group.directory, group.folderScopes]);
React.useSyncExternalStore(
React.useCallback(
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectory, childStores],
(notify) => bootstrapDirectories.length > 0 ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectories.length, childStores],
),
React.useCallback(
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
[bootstrapDirectory, childStores],
() => bootstrapDirectories.map((directory) => (
`${directory}\u0000${childStores.getBootstrapState(directory) ?? ''}\u0000${childStores.getBootstrapFailure(directory) ?? ''}`
)).join('\u0001'),
[bootstrapDirectories, childStores],
),
React.useCallback(() => undefined, []),
React.useCallback(() => '', []),
);
const bootstrapLoading = bootstrapDirectories.some((directory) => {
const state = childStores.getBootstrapState(directory);
return state === 'queued' || state === 'running';
});
const failedBootstrapDirectory = bootstrapDirectories.find(
(directory) => childStores.getBootstrapState(directory) === 'failed',
) ?? null;
const bootstrapFailure = failedBootstrapDirectory
? childStores.getBootstrapFailure(failedBootstrapDirectory)
: undefined;
const canGrantBootstrapAccess = bootstrapFailure === 'os-permission' && canRequestNativeDirectoryAccess();
const [isRequestingBootstrapAccess, setIsRequestingBootstrapAccess] = React.useState(false);
const retryFailedBootstrap = React.useCallback(() => {
if (!failedBootstrapDirectory) return;
childStores.requestBootstrap({
directory: failedBootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
});
}, [childStores, failedBootstrapDirectory, group.isMain, isCollapsed]);
const grantFailedBootstrapAccess = React.useCallback(async () => {
if (!failedBootstrapDirectory || !canGrantBootstrapAccess || isRequestingBootstrapAccess) return;
setIsRequestingBootstrapAccess(true);
try {
const result = await requestDirectoryAccess(failedBootstrapDirectory);
if (result.success) retryFailedBootstrap();
} finally {
setIsRequestingBootstrapAccess(false);
}
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
const maxVisible = hideDirectoryControls ? 10 : 5;
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
@@ -879,6 +920,33 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
const bootstrapFailureNotice = failedBootstrapDirectory ? (
<span className="inline-flex flex-wrap items-center gap-1.5">
{bootstrapFailure === 'os-permission'
? t('sessions.sidebar.group.empty.permissionDenied')
: t('sessions.sidebar.group.empty.loadFailed')}
{canGrantBootstrapAccess ? (
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
disabled={isRequestingBootstrapAccess}
onClick={() => void grantFailedBootstrapAccess()}
>
{t('sessions.sidebar.group.empty.grantAccess')}
</Button>
) : null}
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
onClick={retryFailedBootstrap}
>
{t('sessions.sidebar.group.empty.retry')}
</Button>
</span>
) : null;
const body = (
<SessionFolderDndScope
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
@@ -971,34 +1039,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: bootstrapState === 'queued' || bootstrapState === 'running'
: bootstrapLoading
? (
<span className="inline-flex items-center gap-1.5">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('sessions.sidebar.group.empty.loadingSessions')}
</span>
)
: bootstrapState === 'failed' && bootstrapDirectory
? (
<span className="inline-flex items-center gap-1.5">
{t('sessions.sidebar.group.empty.loadFailed')}
<button
type="button"
className="text-foreground hover:underline"
onClick={() => childStores.requestBootstrap({
directory: bootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
})}
>
{t('sessions.sidebar.group.empty.retry')}
</button>
</span>
)
: bootstrapFailureNotice
? bootstrapFailureNotice
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{totalSessions > 0 && bootstrapFailureNotice ? (
<div className="py-1 pl-[26px] text-left typography-micro text-status-error">
{bootstrapFailureNotice}
</div>
) : null}
{remainingCount > 0 ? (
<button
type="button"
@@ -22,11 +22,11 @@ import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPi
import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
@@ -34,6 +34,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useHasSessionActivityDuration } from '@/sync/session-activity-timing';
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { useI18n } from '@/lib/i18n';
import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld';
@@ -443,6 +445,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
);
const sessionStatus = useGlobalSessionStatus(session.id);
const statusType = sessionStatus?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
// Read as a boolean, not as the value: the row must not re-render on every
// tick of the counter it only decides to mount.
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
const sessionGoal = getSessionGoal(resolvedSession);
@@ -463,6 +470,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
// expand the other. Matches the format of menuInstanceKey.
const expansionKey = menuInstanceKey;
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
const questionBadgeSessionScopes = React.useMemo(
() => selectQuestionBadgeSessionScopes(node, isExpanded, sessionDirectory),
[isExpanded, node, sessionDirectory],
);
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
const unseenCount = useSessionUnseenCount(session.id);
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
@@ -668,26 +680,31 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
);
}
const statusType = sessionStatus?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
const pendingPermissionCount = sessionPermissions.length;
const pendingQuestionLabel = pendingQuestionCount === 1
? t('sessions.sidebar.session.status.questionPendingSingle')
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount });
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
const showStatusMarker = isStreaming || showUnreadStatus;
const statusMarkerContent = isStreaming
? (
<Icon
name="loader-4"
className="h-3 w-3 animate-spin text-primary"
aria-label={t('sessions.sidebar.session.status.active')}
/>
)
: (
<span
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
aria-label={t('sessions.sidebar.session.status.unread')}
title={t('sessions.sidebar.session.status.unread')}
/>
);
// Both states are the same static dot; only the color separates "running"
// from "unread". The elapsed-turn readout on the right carries the motion
// that a spinner used to, at one repaint per second instead of per frame.
const statusMarkerLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const statusMarkerContent = (
<span
className={cn(
'h-1.5 w-1.5 rounded-full',
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
)}
aria-label={statusMarkerLabel}
title={statusMarkerLabel}
/>
);
// The settled duration lives exactly as long as the unread marker does, so a
// session read (or watched) while it finishes never keeps a stale total.
const showActivityDuration = (isStreaming || showUnreadStatus) && hasActivityDuration;
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
const pinnedMarkerContent = (
@@ -1224,21 +1241,31 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
would reflow the truncated title and cause a micro
horizontal shift when the status flips. */}
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
{/* While a turn runs (and until its result is read) the
elapsed counter takes over this slot from the usual
goal/branch/date metadata, which stays one hover or
one read away. */}
{alwaysShowActions ? (
// Touch runtimes have no hover tooltip, so the compact
// date stays inline there.
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
{sessionCompactUpdatedLabel}
{showActivityDuration ? (
<SessionActivityDuration sessionId={session.id} running={isStreaming} />
) : (
<>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
{sessionCompactUpdatedLabel}
</>
)}
</span>
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
<span className={cn(
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
@@ -1246,14 +1273,24 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? 'opacity-0'
: hideOnHoverClass,
)}>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
{showActivityDuration ? (
<SessionActivityDuration
sessionId={session.id}
running={isStreaming}
className="text-[0.72rem]"
/>
) : null}
) : (
<>
{sessionGoalGlyph}
{showInlineBranchMarker ? (
<Icon
name="git-branch"
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
style={prIconColor ? { color: prIconColor } : undefined}
/>
) : null}
</>
)}
</span>
</div>
) : null}
@@ -1263,6 +1300,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<span className="leading-none">{pendingPermissionCount}</span>
</span>
) : null}
{pendingQuestionCount > 0 ? (
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
<Icon name="question" className="h-3 w-3" />
<span className="leading-none">{pendingQuestionCount}</span>
</span>
) : null}
</div>
</button>
</TooltipTrigger>
@@ -44,7 +44,7 @@ export function SidebarFooter({
<Tooltip>
<TooltipTrigger asChild>
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
<Icon name="question" className="h-4.5 w-4.5" />
<Icon name="command" className="h-4.5 w-4.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
@@ -1,5 +1,4 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import type { CollapsedActivityState } from './collapsedActivityState';
@@ -15,21 +14,13 @@ export function CollapsedActivityIndicator({
className?: string;
}): React.ReactNode {
const label = state === 'active' ? activeLabel : unreadLabel;
if (state === 'active') {
return (
<Icon
name="loader-4"
className={cn('h-3 w-3 shrink-0 animate-spin text-primary', className)}
aria-label={label}
/>
);
}
// Aggregate rows carry the dot only; the elapsed counter is per session and
// has no meaning for a collapsed group that may hold several running turns.
return (
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
'bg-[var(--status-info)]',
state === 'active' ? 'bg-primary' : 'bg-[var(--status-info)]',
className,
)}
aria-label={label}
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
const session = (id: string, title: string): Session => ({
@@ -32,6 +32,41 @@ describe('computeNodeStructureKey', () => {
});
});
describe('selectQuestionBadgeSessionScopes', () => {
const withDirectory = (node: SessionNode, directory: string | null): SessionNode => ({
...node,
session: { ...node.session, directory } as Session,
});
test('rolls up the hidden subtree by owning directory when a parent is collapsed', () => {
const grandchild = withDirectory({ session: session('grandchild', 'Grandchild'), children: [], worktree: null }, '/worktrees/feature');
const child = withDirectory({ session: session('child', 'Child'), children: [grandchild], worktree: null }, '/worktrees/feature');
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
expect(selectQuestionBadgeSessionScopes(root, false, '/repo')).toEqual([
{ directory: '/repo', sessionIDs: ['root'] },
{ directory: '/worktrees/feature', sessionIDs: ['child', 'grandchild'] },
]);
});
test('keeps expanded rows accurate to their own session only', () => {
const child = withDirectory({ session: session('child', 'Child'), children: [], worktree: null }, '/worktrees/feature');
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
expect(selectQuestionBadgeSessionScopes(root, true, '/repo')).toEqual([
{ directory: '/repo', sessionIDs: ['root'] },
]);
});
test('falls back to the group directory when the session has none', () => {
const root: SessionNode = { session: session('root', 'Root'), children: [], worktree: null };
expect(selectQuestionBadgeSessionScopes(root, false, '/fallback')).toEqual([
{ directory: '/fallback', sessionIDs: ['root'] },
]);
});
});
describe('nodeHasPinnedMembershipChange', () => {
test('detects composite pin changes using the group directory fallback', () => {
const node: SessionNode = {
@@ -1,4 +1,6 @@
import { getRuntimeKey } from '@/lib/runtime-switch';
import { normalizePath } from '@/lib/pathNormalization';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import type { SessionNode } from './types';
@@ -70,6 +72,41 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
return false;
};
export type QuestionBadgeSessionScope = {
directory: string;
sessionIDs: string[];
};
/**
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
* badge should count. An expanded row counts only its own session; a collapsed
* parent row additionally rolls up the hidden descendants of its subtree,
* grouped by the directory store each descendant actually lives in, so badges
* stay correct for worktree/subtask sessions without bootstrapping their
* directory stores.
*/
export const selectQuestionBadgeSessionScopes = (
node: SessionNode,
isExpanded: boolean,
fallbackDirectory: string | null,
): QuestionBadgeSessionScope[] => {
const sessionIDsByDirectory = new Map<string, string[]>();
const visit = (current: SessionNode): void => {
const directory = resolveGlobalSessionDirectory(current.session)
?? normalizePath(current.worktree?.path)
?? fallbackDirectory;
if (directory) {
const sessionIDs = sessionIDsByDirectory.get(directory) ?? [];
sessionIDs.push(current.session.id);
sessionIDsByDirectory.set(directory, sessionIDs);
}
if (current === node && isExpanded) return;
for (const child of current.children) visit(child);
};
visit(node);
return [...sessionIDsByDirectory].map(([directory, sessionIDs]) => ({ directory, sessionIDs }));
};
export const selectFolderRootNodes = (
sessionIds: string[],
nodeBySessionId: ReadonlyMap<string, SessionNode>,
@@ -1,5 +1,5 @@
import React from 'react';
import { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
import { cn } from '@/lib/utils';
import type { TerminalTheme } from '@/lib/terminalTheme';
@@ -15,8 +15,33 @@ import {
} from '@/lib/terminalTouchSelection';
import type { TerminalChunk } from '@/stores/useTerminalStore';
let ghosttyPromise: Promise<Ghostty> | null = null;
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView
// stays eagerly importable for the bottom dock without pulling the emulator
// into the startup graph before a terminal is actually mounted.
type GhosttyModule = typeof import('ghostty-web');
type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty };
let ghosttyRuntimePromise: Promise<GhosttyRuntime> | null = null;
const loadGhostty = (): Promise<GhosttyRuntime> =>
ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({
module,
ghostty: await module.Ghostty.load(),
}));
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
// it with a short bound so a cached font is in place before the glyph atlas is
// built, while a cold CDN fetch never blocks the terminal from opening; the
// runtimes without the hook (VS Code, mobile) resolve immediately.
const NERD_FONT_WAIT_MS = 2000;
const ensureNerdFonts = (): Promise<void> => {
if (typeof window === 'undefined') return Promise.resolve();
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
if (typeof loader !== 'function') return Promise.resolve();
return Promise.race([
Promise.resolve(loader()).catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
]).then(() => undefined);
};
type TerminalSize = { cols: number; rows: number };
@@ -211,13 +236,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur);
loadGhostty().then((ghostty) => {
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
if (disposed) return;
terminal = new GhosttyTerminal({
terminal = new module.Terminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
...(provisionalSizeRef.current ?? {}),
});
const fitAddon = new FitAddon();
const fitAddon = new module.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
terminalRef.current = terminal;
@@ -41,7 +41,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
import { getSettingsNavIcon } from '@/components/views/SettingsView';
import { getSettingsNavIcon } from '@/lib/settings/metadata';
import { Icon } from "@/components/icon/Icon";
import { McpIcon } from '@/components/icons/McpIcon';
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
+6 -6
View File
@@ -175,6 +175,11 @@ export const HelpDialog: React.FC = () => {
icon: "time",
keys: '',
},
{
keys: [`${mod} + 1...0`],
descriptionKey: "helpDialog.item.switchContextSurface",
icon: "layout-right",
},
],
},
{
@@ -186,11 +191,6 @@ export const HelpDialog: React.FC = () => {
icon: "palette",
keys: '',
},
{
keys: [`${mod} + 1...9`],
descriptionKey: "helpDialog.item.switchProject",
icon: "layout-left",
},
{
id: 'toggle_services_menu',
descriptionKey: 'helpDialog.item.toggleServicesMenu',
@@ -218,7 +218,7 @@ export const HelpDialog: React.FC = () => {
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon name="settings-3" className="h-5 w-5" />
<Icon name="command" className="h-5 w-5" />
{t('helpDialog.title')}
</DialogTitle>
<DialogDescription>
@@ -25,6 +25,12 @@ const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
};
// Desktop shells (Electron, VS Code webview) use persistent, not
// auto-hiding, scrollbars. Reads the same `desktop-runtime` root class that
// index.css keys off, so JS and CSS never disagree on what counts as desktop.
const isDesktopScrollbarRuntime = (): boolean =>
typeof document !== "undefined" && document.documentElement.classList.contains("desktop-runtime");
const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
containerRef,
minThumbSize = 32,
@@ -128,8 +134,15 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
if (isHoveringRef.current) {
return;
}
// Desktop shells keep the thumb visible once shown instead of
// auto-hiding it after a delay. userIntentOnly callers (e.g. chat
// auto-follow scroll) opt out of persistence on purpose, so they keep
// the existing auto-hide behavior even on desktop.
if (isDesktopScrollbarRuntime() && !userIntentOnly) {
return;
}
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
}, [hideDelayMs]);
}, [hideDelayMs, userIntentOnly]);
const markUserIntent = React.useCallback(() => {
lastUserIntentAtRef.current = Date.now();
@@ -162,7 +175,10 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
if (!container) return;
updateMetrics();
setVisible(false);
// On desktop shells, show the thumb immediately if content overflows
// instead of waiting for the first scroll event (persistent affordance,
// matching native desktop scrollbar conventions).
setVisible(isDesktopScrollbarRuntime() && !userIntentOnly);
const onScroll = () => handleScroll();
const onKeyDown = (event: KeyboardEvent) => {
+21 -2
View File
@@ -1417,12 +1417,32 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// The repository's own default branch, so a repo whose default is neither
// main, master nor develop stops being compared against a branch that does
// not exist.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
const updateTargetBranch = React.useMemo(() => {
const remoteNames = effectiveRemotes.map((remote) => remote.name);
@@ -1511,7 +1531,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const stagedCount = stagedChangeEntries.length;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
const currentBranch = status?.current ?? null;
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
@@ -213,14 +213,32 @@ export const PullRequestView: React.FC = () => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// A pull request opened against a branch that does not exist is worse than a
// broken walkthrough, so this surface reads the repository's default branch
// too rather than guessing at main/master/develop.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const currentBranch = status?.current ?? null;
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
if (!currentDirectory || !currentBranch) {
return (
@@ -46,7 +46,6 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { McpIcon } from '@/components/icons/McpIcon';
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
import {
@@ -55,6 +54,7 @@ import {
} from '@/stores/usePendingOpenCodeRestartStore';
import {
SETTINGS_PAGE_METADATA,
getSettingsNavIcon,
getSettingsPageMeta,
resolveSettingsSlug,
type SettingsPageSlug,
@@ -117,7 +117,6 @@ const pageOrder: SettingsPageSlug[] = [
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
@@ -175,65 +174,6 @@ function getCurrentHistoryState(): Record<string, unknown> {
return window.history.state;
}
// eslint-disable-next-line react-refresh/only-export-components
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
switch (slug) {
case 'general':
return 'settings-3';
case 'projects':
return 'folders';
case 'remote-instances':
return 'computer';
case 'appearance':
return 'palette';
case 'chat':
return 'chat-ai-3';
case 'magic-prompts':
return 'ai-generate-2';
case 'snippets':
return SNIPPETS_SETTINGS_ICON.icon;
case 'notifications':
return 'notification-3';
case 'shortcuts':
return 'command';
case 'sessions':
return 'chat-history';
case 'providers':
return 'cloud';
case 'agents':
return 'ai-agent';
case 'behavior':
return 'brain';
case 'commands':
return 'slash-commands-2';
case 'mcp':
return null;
case 'plugins':
return 'plug-2';
case 'skills.installed':
return 'book-open';
case 'skills.catalog':
return 'book';
case 'git':
return 'git-branch';
case 'usage':
return 'bar-chart-2';
case 'voice':
return 'mic';
case 'tunnel':
return 'home-office';
case 'about':
return 'information';
case 'home':
return null;
default:
return 'robot-2';
}
}
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
const { t } = useI18n();
@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test';
import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch';
describe('deriveBaseBranch', () => {
test('prefers the repository default branch over conventional fallbacks', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
defaultBranch: 'react',
})).toBe('react');
});
test('accepts a remote-qualified default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
defaultBranch: 'origin/react',
})).toBe('react');
});
test('keeps the more specific worktree origin ahead of the default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react', 'feature'],
worktreeCreatedFromBranch: 'feature',
defaultBranch: 'react',
})).toBe('feature');
});
test('skips a hint that is the branch being compared', () => {
// In a plain checkout the project root is the current worktree, so the root
// branch hint is the current branch — a branch is never its own base.
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react'],
rootBranchHint: 'next',
defaultBranch: 'react',
headBranch: 'next',
})).toBe('react');
});
test('falls back to conventional names when nothing is known', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['master', 'next'],
})).toBe('master');
});
});
describe('hasResolvableBaseBranch', () => {
test('rejects the main fallback when it does not exist', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next', 'react'],
remoteBranches: ['origin/next', 'origin/react'],
})).toBe(false);
});
test('accepts a base branch available through a remote-tracking ref', () => {
// Safe because getRangeDiff resolves a base that exists only on a remote
// through that remote rather than passing the bare name to git.
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/main', 'origin/next'],
})).toBe(true);
});
test('does not accept a differently-scoped branch that merely ends the same way', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/feature/main'],
})).toBe(false);
});
test('matches a base branch whose own name contains a slash', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'release/2.0',
localBranches: ['next'],
remoteBranches: ['origin/release/2.0'],
})).toBe(true);
});
});
@@ -8,8 +8,30 @@ export const deriveBaseBranch = (options: {
localBranches: readonly string[];
worktreeCreatedFromBranch?: string | null;
rootBranchHint?: string | null;
/**
* The repository's own default branch, read from a `remote/HEAD` symbolic
* ref. Its own option rather than another hint: `rootBranchHint` means "the
* branch the project root worktree is on", and a parameter that means two
* things is one the next caller gets wrong.
*/
defaultBranch?: string | null;
/**
* The branch being compared. A branch is never its own base, so a candidate
* equal to it is skipped in a plain checkout `rootBranchHint` *is* the
* current branch, and taking it produced a comparison with itself.
*/
headBranch?: string | null;
}): string => {
const { remoteNames, localBranches, worktreeCreatedFromBranch, rootBranchHint } = options;
const {
remoteNames,
localBranches,
worktreeCreatedFromBranch,
rootBranchHint,
defaultBranch,
headBranch,
} = options;
const head = typeof headBranch === 'string' ? headBranch.trim() : '';
const normalizeBaseCandidate = (value: string): string => {
if (!value) {
@@ -49,16 +71,47 @@ export const deriveBaseBranch = (options: {
return normalized;
};
const fromMeta = normalizeBaseCandidate(
typeof worktreeCreatedFromBranch === 'string' ? worktreeCreatedFromBranch : ''
);
const candidate = (value: unknown): string => {
const normalized = normalizeBaseCandidate(typeof value === 'string' ? value : '');
return normalized && normalized !== head ? normalized : '';
};
const fromMeta = candidate(worktreeCreatedFromBranch);
if (fromMeta) return fromMeta;
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
const fromHint = candidate(rootBranchHint);
if (fromHint) return fromHint;
// Authoritative where the hints are guesses: this is what the repository says
// its default branch is, so it outranks the conventional names below.
const fromDefault = candidate(defaultBranch);
if (fromDefault) return fromDefault;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
return 'main';
};
/**
* Whether a base branch can be resolved locally or through one of the active
* remote-tracking refs. Callers must not offer comparisons against the `main`
* fallback when that ref does not actually exist in the repository.
*
* `remoteBranches` are remote-relative (`origin/main`, `origin/feature/x`), so
* the remote name is dropped and the rest compared whole. A suffix test matched
* `origin/feature/main` for a base of `main`, which passes the check and then
* fails the comparison it was meant to prevent.
*/
export const hasResolvableBaseBranch = (options: {
baseBranch: string;
localBranches: readonly string[];
remoteBranches: readonly string[];
}): boolean => {
const { baseBranch, localBranches, remoteBranches } = options;
if (localBranches.includes(baseBranch)) return true;
return remoteBranches.some((branch) => {
const slashIndex = branch.indexOf('/');
return slashIndex > 0 && branch.slice(slashIndex + 1) === baseBranch;
});
};
@@ -6,10 +6,10 @@ import { useI18n } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { updateDesktopSettings } from '@/lib/persistence';
import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types';
import type { WalkthroughBlockedState, WalkthroughModel } from '@/lib/walkthrough/types';
interface WalkthroughBlockerProps {
reason: WalkthroughBlockedReason;
reason: WalkthroughBlockedState;
model?: WalkthroughModel;
requiredChars?: number;
availableChars?: number;
@@ -102,6 +102,7 @@ export const WalkthroughBlocker = ({
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.description');
if (reason === 'output-exhausted') {
return label
? t('walkthrough.blocked.outputExhausted.description', { model: label })
@@ -125,6 +126,7 @@ export const WalkthroughBlocker = ({
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.title');
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
return t('walkthrough.blocked.contextTooSmall.title');
@@ -150,13 +152,15 @@ export const WalkthroughBlocker = ({
onChange={(providerId, modelId) => {
void handleModelChange(providerId, modelId);
}}
allowedProviderIds={providers}
allowedProviderIds={providers ?? []}
isModelAllowed={isStructuredOutputCapable}
/>
</div>
)}
{(reason === 'empty-diff' || reason === 'only-generated') && (
{/* Retry is the whole remedy once the server is updated, so it stays in
reach rather than sending the user back through the panel header. */}
{(reason === 'empty-diff' || reason === 'only-generated' || reason === 'server-unsupported') && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
{t('walkthrough.action.refresh')}
</Button>
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Icon } from '@/components/icon/Icon';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { groupHunksByFile } from '@/lib/walkthrough/model';
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
@@ -20,8 +21,13 @@ interface WalkthroughStreamProps {
wrapLines: boolean;
}
// Importance says where to spend attention, not what is wrong: a stop is marked
// because it drives the rest of the change, never because something was found in
// it. A red pill said the opposite — status colours are read as findings, and a
// walkthrough deliberately hands out no verdicts — so the emphasis is carried by
// weight and an outline instead, and the tooltip states the axis outright.
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
critical: 'bg-status-error/10 text-status-error',
critical: 'border border-[var(--interactive-border)] font-medium text-foreground',
normal: 'bg-surface-muted text-muted-foreground',
context: 'bg-surface-muted text-muted-foreground',
};
@@ -41,11 +47,22 @@ const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => {
exactly as tall as one without: vertical padding on a smaller type
size was pushing past the tallest element in the row. */}
{stop.importance !== 'normal' && (
<span className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}>
{stop.importance === 'critical'
? t('walkthrough.importance.critical')
: t('walkthrough.importance.context')}
</span>
<Tooltip>
<TooltipTrigger
className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}
>
{stop.importance === 'critical'
? t('walkthrough.importance.critical')
: t('walkthrough.importance.context')}
</TooltipTrigger>
<TooltipContent className="max-w-64">
<p className="typography-micro leading-tight">
{stop.importance === 'critical'
? t('walkthrough.importance.criticalHint')
: t('walkthrough.importance.contextHint')}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<p className="typography-body text-muted-foreground">{stop.prose}</p>
@@ -10,14 +10,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n, type Locale } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { buildWalkthroughView } from '@/lib/walkthrough/model';
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { deriveBaseBranch } from '@/components/views/git/baseBranch';
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitBranches, useGitStatus } from '@/stores/useGitStore';
import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import {
getFreshestPrStatusForBranch,
@@ -41,6 +43,12 @@ interface WalkthroughViewProps {
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
// What a walkthrough is — and what it deliberately is not — cannot be read off
// the panel: the first question users asked about it was whether its marks were
// review findings. The guide answers that, so it is reachable from the surface
// itself rather than only from the release announcement.
const WALKTHROUGH_GUIDE_URL = 'https://docs.openchamber.dev/walkthrough/';
// DropdownMenuLabel defaults to the same size and weight as its items, which
// makes a heading read as another choice. This matches SelectLabel, the
// treatment used by the worktree picker.
@@ -152,6 +160,12 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const status = useGitStatus(directory || null);
const branches = useGitBranches(directory || null);
const ensureAll = useGitStore((state) => state.ensureAll);
const { github, git } = useRuntimeAPIs();
useEffect(() => {
if (directory) void ensureAll(directory, git);
}, [directory, ensureAll, git]);
// The branch source reviews everything on this branch that is not on its
// base. Three-dot semantics server-side mean merges from the base are
@@ -162,22 +176,33 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
if (!headRef) return null;
const all = branches?.all ?? [];
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
const remoteBranches = all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.slice('remotes/'.length));
const remoteNames = new Set(
all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.slice('remotes/'.length).split('/')[0])
remoteBranches
.map((name) => name.split('/')[0])
.filter(Boolean)
);
const baseRef = deriveBaseBranch({ remoteNames, localBranches });
if (!baseRef || baseRef === headRef) return null;
const trackingRemote = status?.tracking?.split('/')[0];
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
const baseRef = deriveBaseBranch({
remoteNames,
localBranches,
defaultBranch,
headBranch: headRef,
});
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
return null;
}
return { kind: 'branch', baseRef, headRef };
}, [branches, currentBranch]);
}, [branches, currentBranch, status?.tracking]);
// The pull request for this branch used to appear only after visiting the PR
// panel, because nothing else asked GitHub about it. Ask here too: the status
// store already dedupes by signature and throttles by TTL, so several panels
// wanting the same answer produce one request.
const { github } = useRuntimeAPIs();
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
@@ -323,15 +348,36 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
// Explicit pick first, then the model that actually produced what is on
// screen, then whatever settings resolve to. The middle step is what makes
// reopening a review show the model behind it rather than the default.
const activeModel = selectedModel
?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined)
?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined);
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
const activeModelId = activeModelParts.join('/');
// Never present a provider without a usable login as the current selection —
// the picker already hides them from the menu; showing one as selected was
// the whole "why say so?" failure mode.
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
const providerIsAuthenticated = (providerId: string | undefined) => {
if (!providerId) return false;
// Until the auth list loads, do not present a candidate as selected —
// otherwise an unauthenticated config model flashes in the picker.
if (modelProviders === undefined) return false;
return modelProviders.includes(providerId);
};
const readinessModelRef = entry.readiness?.model
&& entry.readiness.model.hasLogin !== false
&& providerIsAuthenticated(entry.readiness.model.providerID)
? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}`
: undefined;
const resultModelRef = entry.result?.model
&& providerIsAuthenticated(entry.result.model.providerID)
? `${entry.result.model.providerID}/${entry.result.model.modelID}`
: undefined;
const selectedModelUsable = selectedModel
&& providerIsAuthenticated(selectedModel.split('/')[0])
? selectedModel
: undefined;
const activeModel = selectedModelUsable ?? resultModelRef ?? readinessModelRef;
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
const activeModelId = activeModelParts.join('/');
useEffect(() => {
if (modelProviders !== undefined) return;
let cancelled = false;
@@ -403,14 +449,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const showStages = startedFromEmptyRef.current
&& (entry.status === 'generating' || stageProgress.holding);
// Auth/login gaps are not a full-panel blocker: hide the unusable model and
// disable Generate instead of explaining a raw provider error.
const blockedReason = entry.error?.code === 'context-too-small'
|| entry.error?.code === 'structured-output-unsupported'
|| entry.error?.code === 'no-model'
|| entry.error?.code === 'empty-diff'
|| entry.error?.code === 'only-generated'
|| entry.error?.code === 'output-exhausted'
// Client-detected rather than reported: the server answered something that
// was not JSON, so it has no walkthrough routes at all.
|| entry.error?.code === 'server-unsupported'
? entry.error.code
: entry.readiness && !entry.readiness.ready && !view
&& entry.readiness.reason !== 'no-provider-login'
? entry.readiness.reason
: undefined;
@@ -420,11 +472,16 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
// Not ready, or no usable selected model, means Generate must not look
// actionable — including when the resolved model has no login.
const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
const handleGenerate = useCallback(
(force: boolean) => {
if (generateDisabled) return;
void generate(directory, source, { force, language: activeLanguage });
},
[activeLanguage, directory, generate, source]
[activeLanguage, directory, generate, generateDisabled, source]
);
return (
@@ -495,6 +552,25 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
</DropdownMenu>
<div className="ml-auto flex min-w-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={t('walkthrough.help.guide')}
onClick={() => {
void openExternalUrl(WALKTHROUGH_GUIDE_URL);
}}
>
<Icon name="question" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="typography-micro leading-tight">{t('walkthrough.help.guide')}</p>
</TooltipContent>
</Tooltip>
{/* A walkthrough nobody can read is worth nothing, so the prose
language is a per-review choice like the model defaulting to the
interface language, which is the best evidence of what the reader
@@ -544,7 +620,8 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
onChange={(providerId, modelId) => {
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
}}
allowedProviderIds={modelProviders}
// While the auth list is loading, allow none — not every provider.
allowedProviderIds={modelProviders ?? []}
isModelAllowed={isStructuredOutputCapable}
tooltipsEnabled={false}
dropdownPortalToBody
@@ -592,7 +669,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
type="button"
variant="outline"
size="sm"
className={WALKTHROUGH_ACTION_CLASS}
className={generateDisabled
? 'border-border text-muted-foreground'
: WALKTHROUGH_ACTION_CLASS}
disabled={generateDisabled}
aria-label={compactHeader
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
: undefined}
@@ -646,6 +726,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
variant="ghost"
size="xs"
className="ml-auto"
disabled={generateDisabled}
// Not forced: if an entry for this exact request existed the banner
// would not be here, and a forced run would refuse the cache it may
// find on the way.
@@ -677,7 +758,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
</div>
)}
{entry.error && !blockedReason && (
{entry.error && !blockedReason && entry.error.code !== 'no-provider-login' && (
<div className="flex shrink-0 items-start gap-2 border-b border-border/60 bg-status-error/10 px-3 py-2">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-status-error" />
{/* Provider errors arrive as raw JSON bodies. Show a readable amount