Merge pull request 'chore: bring upstream v1.20.0 into custom' (#2) from release/v1.20.0 into custom

This commit is contained in:
2026-08-28 22:05:23 -04:00
281 changed files with 10504 additions and 2180 deletions
@@ -0,0 +1,46 @@
import React from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
}));
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
const {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} = await import('./appLinkConfirmation');
describe('AppLinkConfirmDialog', () => {
beforeEach(() => {
if (getAppLinkConfirmationSnapshot()) {
settleAppLinkConfirmation('cancel');
}
});
test('keeps cancel visible and focused beside both open choices', () => {
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
const markup = renderToStaticMarkup(
<I18nProvider>
<AppLinkConfirmDialog />
</I18nProvider>,
);
expect(markup).toContain('>Cancel</button>');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('>Open once</button>');
expect(markup).toContain('>Trust and open</button>');
settleAppLinkConfirmation('cancel');
});
});
@@ -0,0 +1,77 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getUrlScheme } from '@/lib/url';
import {
getAppLinkConfirmationSnapshot,
settleAppLinkConfirmation,
subscribeAppLinkConfirmation,
type AppLinkConfirmationChoice,
} from './appLinkConfirmation';
/**
* App-level dialog confirming application deep links (obsidian://, vscode://,
* ...) rendered in chat markdown before the OS is asked to open them.
* Dismissing via the close button, Escape, or the backdrop cancels the open.
*/
export const AppLinkConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeAppLinkConfirmation,
getAppLinkConfirmationSnapshot,
getAppLinkConfirmationSnapshot,
);
const url = request?.url ?? '';
const scheme = getUrlScheme(url) ?? '';
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
settleAppLinkConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('cancel');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
<DialogDescription>
{scheme
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
: t('chat.appLink.confirm.descriptionPlain')}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{url}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
{t('chat.appLink.confirm.cancel')}
</Button>
<Button variant="outline" onClick={() => settle('trust')}>
{t('chat.appLink.confirm.trustAndOpen')}
</Button>
<Button variant="default" onClick={() => settle('open')}>
{t('chat.appLink.confirm.open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+161 -148
View File
@@ -60,11 +60,14 @@ import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shel
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const DRAFT_EXIT_DURATION_MS = 120;
const COMPOSER_MOVE_DURATION_MS = 180;
const CHAT_SCROLL_STYLE = {
overflowAnchor: 'none',
overscrollBehavior: 'contain',
@@ -502,19 +505,24 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
);
};
const DraftWelcome: React.FC = () => {
const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
const { t } = useI18n();
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
const projectLabel = useProjectsStore(React.useCallback((state) => {
if (draftTarget === 'chat') return null;
const projectId = selectedProjectId ?? state.activeProjectId;
const project = (projectId
? state.projects.find((candidate) => candidate.id === projectId)
: null) ?? state.projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null;
}, [selectedProjectId]));
}, [draftTarget, selectedProjectId]));
return (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<div className={cn(
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
exiting && 'pointer-events-none opacity-0',
)}>
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
projectLabel
@@ -558,6 +566,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -731,12 +741,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isVSCode = isVSCodeRuntime();
const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open);
const isManagedChatContext = draftOpen
? newSessionDraft?.target === 'chat'
: isChatDirectoryPath(effectiveSessionDirectory);
// A draft can target another project or a pending worktree before it has a
// session. Keep the panel on that same directory so its project, MCP, and
// usage readouts describe where the draft will run rather than the project
// the user came from.
const workStatusDirectory = draftOpen
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory
? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
: effectiveSessionDirectory;
const initError = useGlobalSyncStore((s) => s.error);
// Despite the historical name, this now covers mobile too: the mobile
@@ -748,7 +761,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// row that holds both columns, so its width never depends on the panel's
// own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: workStatusDirectory,
isMobile,
isVSCode,
});
@@ -1091,11 +1103,75 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
const previousDraftOpenRef = React.useRef(draftOpen);
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
const shouldAnimateDraftTransition = Boolean(
currentSessionId && materializedDraftSessionId === currentSessionId,
);
const draftPresentationExiting = draftExitAnimating
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
const draftLayoutVisible = draftOpen || draftPresentationExiting;
React.useLayoutEffect(() => {
if (draftOpen) {
setDraftExitAnimating(false);
return;
}
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
setDraftExitAnimating(true);
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
return () => window.clearTimeout(timeoutId);
}, [draftOpen, shouldAnimateDraftTransition]);
React.useLayoutEffect(() => {
previousDraftOpenRef.current = draftOpen;
}, [draftOpen]);
React.useLayoutEffect(() => {
const composerSlot = composerSlotRef.current;
if (!composerSlot) return;
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
const previousRect = previousComposerRectRef.current;
const leftDraftLayout = previousDraftLayoutVisibleRef.current
&& !draftLayoutVisible
&& Boolean(currentSessionId);
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
const deltaX = previousRect.left - currentRect.left;
const deltaY = previousRect.top - currentRect.top;
composerSlot.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
{ transform: 'translate(0, 0)' },
],
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
);
}
previousComposerRectRef.current = currentRect;
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
if (leftDraftLayout && currentSessionId) {
clearMaterializedDraftSession(currentSessionId);
}
}, [
clearMaterializedDraftSession,
currentSessionId,
draftLayoutVisible,
isDesktopExpandedInput,
shouldAnimateDraftTransition,
useCompactDraftLayout,
]);
if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below),
// so the empty state is only ever transient here — render a neutral
// background instead of flashing the logo / "start a new chat" on refresh.
// Keep the empty state when there's nothing to auto-open or an init error to show.
// The auto-open effect runs on the next tick. Use a neutral background
// until then instead of flashing the standard empty state.
if (autoOpenDraft && !initError) {
return <div className="flex h-full flex-col bg-background" />;
}
@@ -1106,82 +1182,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
);
}
if (!currentSessionId && draftOpen) {
return (
// No transform on this root: it would become the containing block for
// the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect).
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
);
}
const sessionSurface = (() => {
if (draftOpen || draftPresentationExiting) {
if (!useCompactDraftLayout || isDesktopExpandedInput) {
return null;
}
return <DraftWelcome exiting={draftPresentationExiting} />;
}
if (!currentSessionId) {
return null;
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
);
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
return (
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
@@ -1192,20 +1223,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
{item.toolRows.map((row) => (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
))}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
{item.textWidths.map((width, index) => (
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
))}
</div>
</div>
</div>
@@ -1214,62 +1243,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
</div>
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
// No transform here either — same fixed-positioning constraint as the
// draft branch above.
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
{!isDesktopExpandedInput ? (
<div className="absolute inset-0 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
/>
);
}
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
currentSessionKey={currentSessionKey ?? currentSessionId}
return (
<ChatViewport
currentSessionId={currentSessionId ?? ''}
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
@@ -1300,22 +1292,41 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
isLoadingOlderPrompts={timelineController.isLoadingOlder}
onLoadEarlierPrompts={handleLoadOlderClick}
/>
);
})();
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
{sessionSurface}
<div
ref={composerSlotRef}
className={cn(
'relative z-10',
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: draftLayoutVisible && !useCompactDraftLayout
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
)}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
) : (
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
draftPresentationExiting={draftPresentationExiting}
/>
)}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
@@ -1327,6 +1338,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/>
) : null}
@@ -1349,6 +1361,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/>
) : null}
</div>
+149 -48
View File
@@ -34,6 +34,9 @@ import {
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -51,7 +54,7 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
// useMessageStore removed — messages now come from sync system
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -229,6 +232,7 @@ interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
active?: boolean;
draftPresentationExiting?: boolean;
}
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
@@ -242,7 +246,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
active = true,
draftPresentationExiting = false,
}) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
@@ -314,6 +323,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
// btw mode: the CURRENT session's metadata links an active btw fork and
// the panel is expanded, so this composer's sends route to the fork
// instead of the main session. Collapsed keeps the fork alive (chip stays
// visible) while the composer talks to the main session again.
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
const btwSessionId = btwPanel.btwSessionId;
const btwDirectory = btwPanel.btwDirectory;
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
() => (currentSessionId && btwSessionId && btwDirectory
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
: null),
[btwDirectory, btwSessionId, currentSessionId],
);
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity(
@@ -331,6 +354,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const attachedFiles = useInputStore((s) => s.attachedFiles);
@@ -341,6 +365,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const pendingInputText = useInputStore((s) => s.pendingInputText);
React.useEffect(() => {
if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return;
void prepareChatDraftDirectory();
}, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]);
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
const abortCurrentOperation = React.useCallback(
@@ -557,7 +586,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const availableSkills = useSkillsStore((s) => s.skills);
const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
]);
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
for (const command of availableCommands) names.add(command.name.toLowerCase());
@@ -832,8 +861,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
}, [newSessionDraftOpen, isMobile]);
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
// Session activity for queue availability and controls. In btw mode the
// composer controls the temporary fork, so the stop button and send-button
// state follow the FORK's activity; the queue affordance stays tied to the
// main session (queued messages always belong to the main chat).
const { phase: currentSessionPhase } = useCurrentSessionActivity();
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return false;
const run = state.runsByOriginalSessionID[currentSessionId];
@@ -1043,12 +1077,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly && autoReviewRunning) {
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
handleQueueMessage();
return;
}
if (currentSessionId && !queuedOnly) {
// btw mode: the child fork's blocking prompts are answered inside the
// panel; the composer send goes straight to the fork (routeMessage
// queues if the fork's own turn is busy).
if (currentSessionId && !queuedOnly && !isBtwActive) {
// Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear
@@ -1068,17 +1105,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sendMessageOptions: {
let sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
sessionId?: string;
directory?: string;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
? {
...(capturedTarget ? { target: capturedTarget } : {}),
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
...(delivery ? { delivery } : {}),
}
: undefined;
} | undefined;
if (isBtwActive && btwSessionId && btwDirectory) {
sendMessageOptions = {
sessionId: btwSessionId,
directory: btwDirectory,
};
} else if (capturedTarget || capturedDraftSnapshot || delivery) {
sendMessageOptions = {};
if (capturedTarget) sendMessageOptions.target = capturedTarget;
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
}
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([
@@ -1219,6 +1263,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
return;
}
if (commandName === 'btw' && currentSessionId) {
const question = argument.trim();
if (!question) {
toast.error(t('chat.btw.toast.emptyArgument'));
return;
}
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|| currentDirectory
|| null;
if (!targetDirectory) {
toast.error(t('chat.btw.toast.createFailed'));
return;
}
try {
// A new btw replaces this session's current one: destroy
// the previous fork first so forks never accumulate.
if (btwSessionRef) {
await destroyBtwSession(btwSessionRef);
}
await startBtwSession({
parentSessionId: currentSessionId,
question,
directory: targetDirectory,
providerID: providerIdToSend,
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
});
scrollToBottom?.();
} catch (error) {
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
}
return;
}
// The rest render a visible prompt plus synthetic instructions and
// send them as one message.
@@ -1254,7 +1332,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
// btw mode: the fork already carries the question plus full history,
// so the response-style instruction never applies there.
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
if (responseStyleInstruction) {
@@ -1424,7 +1504,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Primary action for send/queue button — respects selected follow-up behavior
const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue' && canQueue) {
handleQueueMessage();
} else if (followUpBehavior === 'steer' && canQueue) {
@@ -1432,7 +1512,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else {
void handleSubmitRef.current();
}
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
}, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
// Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
@@ -1636,15 +1716,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
// Handle Enter/Ctrl+Enter based on selected follow-up behavior.
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
// mobile, and in desktop focus mode, plain Enter writes a newline and
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
// prompts, where an accidental send costs more than an extra keypress.
const requiresModifierToSend = isMobile || isDesktopExpanded;
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
// Queueing / steering only works when there's an existing busy
// session (or an active auto-review run).
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue') {
if (isCtrlEnter || !canQueue) {
@@ -1694,8 +1778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
clearAbortPrompt();
startAbortIndicator();
void abortCurrentOperation(currentSessionId || undefined);
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
// btw mode: the stop button stops the fork's turn, not the main
// session's.
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
void abortCurrentOperation(abortTarget || undefined);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
@@ -2392,6 +2479,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const chatSurfaceMode = useChatSurfaceMode();
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting)
&& !isDesktopExpanded
&& !isMobile
&& !isVSCode
&& !isMiniChatSurface;
const draftPresentationClassName = cn(
'transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
draftPresentationExiting && 'pointer-events-none opacity-0',
);
const hasPendingChanges = React.useMemo(() => {
if (isMiniChatSurface) {
@@ -2405,7 +2501,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
return;
}
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
@@ -2569,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
<div className="chat-input-column mb-7 text-center">
{showDesktopDraftPresentation ? (
<div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
{renderDraftTitle(
draftProjectLabel
@@ -2645,21 +2741,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? null
: <PendingChangesBar />}
/>
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<DraftTargetSelectors
projects={draftProjects}
selectedProject={selectedDraftProject}
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
showBranchSelector={shouldShowDraftBranchSelector}
onProjectChange={handleDraftProjectChange}
onDirectoryChange={handleDraftDirectoryChange}
theme={currentTheme}
/>
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
<div className={draftPresentationClassName}>
<DraftTargetSelectors
projects={draftProjects}
selectedProject={selectedDraftProject}
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
showBranchSelector={shouldShowDraftBranchSelector}
onProjectChange={handleDraftProjectChange}
onDirectoryChange={handleDraftDirectoryChange}
theme={currentTheme}
/>
</div>
) : null}
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<MobileDraftTargetTriggers
@@ -2826,11 +2924,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}}
onFocus={mobileShell.onEditorFocus}
onBlur={mobileShell.onEditorBlur}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
placeholder={isBtwActive
? t('chat.btw.mainComposerPlaceholder')
: currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')}
editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={isMobile}
autoCapitalize={isMobile ? 'sentences' : 'none'}
@@ -2923,12 +3023,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
) : null}
</div>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
{showDesktopDraftPresentation ? (
<DraftPresetChips
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
className="chat-input-column mt-4"
className={cn('chat-input-column mt-4', draftPresentationClassName)}
/>
) : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
</form>
{/* Issue Picker Dialog */}
@@ -12,6 +12,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
@@ -202,6 +203,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -1044,7 +1046,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
{/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -152,6 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -226,6 +231,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -376,6 +385,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
return (
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
<div
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +481,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{commands.length === 0 && (
+28 -12
View File
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import {
useWorkerHighlightedLines,
type WorkerHighlightedLinesResult,
} from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
// back to plain text while loading or on failure.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
html !== undefined ? (
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<span className="whitespace-pre-wrap break-all">{content}</span>
);
// Keep the line's layout stable while a cold worker request finishes. Plain
// text appears only if highlighting fails, avoiding a visible color flash.
interface CodeLineContentProps {
content: string;
html: string | undefined;
status: WorkerHighlightedLinesResult['status'];
}
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
if (status === 'ready' && html !== undefined) {
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (status === 'loading') {
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
}
return <span className="whitespace-pre-wrap break-all">{content}</span>;
};
interface DiffPreviewProps {
diff: string;
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div>
{hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor];
const html = highlighted.lines?.[lineCursor];
lineCursor += 1;
return (
<div
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} />
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
</div>
</div>
);
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
<CodeLineContent
content={line || ' '}
html={highlighted.lines?.[lineIdx]}
status={highlighted.status}
/>
</div>
</div>
))}
@@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -80,7 +81,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -458,6 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +472,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme';
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
import { attachAppLinkInteractions } from './appLinkInteractions';
import type { ToolPopupContent } from './message/types';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { useUIStore } from '@/stores/useUIStore';
@@ -42,6 +43,7 @@ import {
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
const useCurrentMermaidTheme = () => {
@@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => {
: fallbackLight);
};
const useExternalLinkInteractions = ({
const useLinkInteractions = ({
containerRef,
enabled,
}: {
@@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
return attachAppLinkInteractions(container, {
allowExternalHttp: enabled !== false,
openAppLink: (href) => void openAppLinkWithConfirmation(href),
openExternalHttp: (href) => void openExternalUrl(href),
});
}, [containerRef, enabled]);
};
@@ -151,19 +121,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// output. The regex is defined in `./fileReferenceParser`; the inline-code
// pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -361,61 +321,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
@@ -521,7 +426,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
@@ -1034,7 +939,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useLinkInteractions({ containerRef });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
@@ -1085,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
// App links remain confirmed even where ordinary HTTP link handling is off.
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
@@ -1126,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences,
});
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
@@ -57,6 +57,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
const AgentDescriptionTooltip: React.FC<{
description?: string;
children: React.ReactElement;
}> = ({ description, children }) => {
if (!description) {
return children;
}
return (
<Tooltip delayDuration={450}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<span className="typography-meta text-muted-foreground">{description}</span>
</TooltipContent>
</Tooltip>
);
};
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
@@ -893,25 +915,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
} else if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
}
if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') {
if (savedAgentName && currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -925,16 +951,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
continue;
}
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -2316,6 +2341,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent
side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
align="end"
alignOffset={-40}
@@ -2618,7 +2644,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
@@ -2708,7 +2734,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<div className="p-2 border-b border-border/40">
<div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2746,12 +2772,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
) : (
sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
@@ -2759,13 +2784,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
</DropdownMenuItem>
</AgentDescriptionTooltip>
))
)}
</div>
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
interface SkillInfo {
name: string;
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
<div
key={`${skill.name}-${skill.scope}`}
ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
};
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
+11 -5
View File
@@ -306,13 +306,19 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return (
<div
// Mobile: breathing room between the last message and the agent status
// line — without it the "<model> is running…" row sits flush against
// the message above.
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
// This row must land exactly where the assistant turn footer (mt-2
// inside the message) appears when the turn completes. Measured against
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// the swap used to lift the line up. mb-6 (24px) reserves that space
// under this row instead (verified: row top 636 == footer top 636).
className={cn("mb-6", !hasLeftAccessory && "chat-column")}
style={STATUS_ROW_CONTAINER_STYLE}
>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
});
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
test('the empty and idle branch leaves the status row to the busy path', () => {
// A busy session with no messages yet must fall through to the viewport so
// StatusRowContainer is the only thing on screen. The idle branch returns
// before it and must not render one of its own. The empty state itself no
// longer lives here: the draft surface owns it since the draft transition
// animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<ChatEmptyState');
expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
expect(emptyStateReturn).toBeGreaterThan(-1);
const emptyStateBlock = chatContainerSource.slice(
emptyStateReturn,
emptyStateReturn + 1600,
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
expect(emptyIdleReturn).toBeGreaterThan(-1);
const emptyIdleBlock = chatContainerSource.slice(
emptyIdleReturn,
emptyIdleReturn + 1600,
);
expect(emptyStateBlock).toContain('<ChatEmptyState');
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
@@ -0,0 +1,33 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
*
* Restoring persisted agent/model pairs used to switch agents before checking
* whether each model still existed. Several stale pairs could therefore keep
* changing the active agent on every effect pass until React hit its nested
* update limit. The API error belongs in the assistant message; an invalid
* persisted pair must not mutate the current selection while it is rendered.
*/
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 modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
describe('issue #3036 stale persisted models', () => {
test('changes the agent only after its persisted model is accepted', () => {
const candidateLoop = modelControlsSource.slice(
modelControlsSource.indexOf('for (const agent of agents)'),
modelControlsSource.indexOf("return 'continue';"),
);
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
expect(applyIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThan(applyIndex);
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
});
});
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} from './appLinkConfirmation';
describe('app link confirmation', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
const pending = getAppLinkConfirmationSnapshot();
if (pending) {
settleAppLinkConfirmation('cancel');
}
});
test('opens trusted schemes without asking', async () => {
useAppLinkTrustStore.getState().trustScheme('obsidian');
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
});
test('asks once and trusts the scheme when the user chooses trust', async () => {
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
settleAppLinkConfirmation('trust');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
});
test('cancel opens nothing and keeps the scheme untrusted', async () => {
const pending = openAppLinkWithConfirmation('notion://note/xyz');
settleAppLinkConfirmation('cancel');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
});
test('a newer request cancels the pending one', async () => {
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
const firstChoice = first.then(
() => 'settled',
() => 'settled',
);
const second = openAppLinkWithConfirmation('linear://open/1');
expect(await firstChoice).toBe('settled');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
settleAppLinkConfirmation('open');
await second;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
type PendingAppLinkRequest = {
url: string;
resolve: (choice: AppLinkConfirmationChoice) => void;
};
let pendingRequest: PendingAppLinkRequest | null = null;
const listeners = new Set<() => void>();
const emitChange = (): void => {
for (const listener of listeners) {
listener();
}
};
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/**
* Ask the user (via the app-level confirmation dialog) whether an application
* deep link may be opened. Resolves immediately when the scheme was trusted
* earlier. Only one request is active at a time; a new request cancels the
* pending one.
*/
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
const scheme = getUrlScheme(url);
if (!scheme) {
return Promise.resolve();
}
const trustStore = useAppLinkTrustStore.getState();
if (trustStore.isSchemeTrusted(scheme)) {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
if (pendingRequest) {
pendingRequest.resolve('cancel');
}
return new Promise<AppLinkConfirmationChoice>((resolve) => {
pendingRequest = { url, resolve };
emitChange();
}).then((choice) => {
if (choice === 'trust') {
useAppLinkTrustStore.getState().trustScheme(scheme);
}
if (choice === 'open' || choice === 'trust') {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
});
};
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
const request = pendingRequest;
pendingRequest = null;
emitChange();
request?.resolve(choice);
};
export const subscribeAppLinkConfirmation = subscribe;
export const getAppLinkConfirmationSnapshot = getSnapshot;
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
type AppLinkInteractionOptions = {
allowExternalHttp: boolean;
openAppLink: (url: string) => void;
openExternalHttp: (url: string) => void;
};
type LinkInteractionContainer = {
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
};
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
return anchor;
};
const interceptAppLink = (
event: MouseEvent | DragEvent,
openAppLink?: (url: string) => void,
): boolean => {
if (event.defaultPrevented) return false;
const anchor = findLink(event);
const href = anchor?.getAttribute('href') ?? '';
if (!isAppLinkUrl(href)) return false;
event.preventDefault();
event.stopPropagation();
openAppLink?.(href);
return true;
};
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
);
export const attachAppLinkInteractions = (
container: LinkInteractionContainer,
options: AppLinkInteractionOptions,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
if (interceptAppLink(event, options.openAppLink)) return;
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
const href = findLink(event)?.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) return;
event.preventDefault();
event.stopPropagation();
options.openExternalHttp(href);
};
const handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) interceptAppLink(event, options.openAppLink);
};
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
interceptAppLink(event);
};
container.addEventListener('click', handleClick);
container.addEventListener('auxclick', handleAuxClick);
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('auxclick', handleAuxClick);
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
};
};
@@ -0,0 +1,481 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile — the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper — data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover oc-glass-floating w-full overflow-hidden rounded-xl">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -1,5 +1,11 @@
import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat';
/**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all — no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -7,6 +7,16 @@ everything between typing and sending.
own state and wires these modules together; it should not grow logic that
belongs to one of them.
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged.
## Layers
| Directory | Owns |
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
kind?: 'chat' | 'project';
}
/** A project's display name, falling back to its directory name. */
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
}
export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
const { t } = useI18n();
const chatProject = React.useMemo<DraftTargetProject>(() => ({
id: CHAT_DRAFT_PROJECT_ID,
path: '',
label: t('layout.mainTab.chat'),
kind: 'chat',
}), [t]);
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
const { git: runtimeGit } = useRuntimeAPIs();
const selectedDraftProject = React.useMemo(() => {
if (newSessionDraft?.target === 'chat') return chatProject;
const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
return active;
}
return projects[0] ?? null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
return configuredProjects[0] ?? chatProject;
}, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path],
() => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.kind, selectedDraftProject?.path],
);
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
? getProjectDisplayLabel(selectedDraftProject)
: null;
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const selectedDraftProjectBranchesFetchedAt = useGitStore(
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
if (!project) {
return;
}
if (project.kind === 'chat') {
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
return;
}
if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId);
}
@@ -0,0 +1,40 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AutocompleteRowTooltipProps {
description?: string;
active: boolean;
children: React.ReactElement;
}
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
const [delayedActive, setDelayedActive] = React.useState(false);
React.useEffect(() => {
if (!active || !description) {
setDelayedActive(false);
return;
}
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
return () => window.clearTimeout(timeout);
}, [active, description]);
if (!description) return children;
return (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -93,7 +93,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
@@ -57,7 +57,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
const fallbackIcon = project.kind === 'chat' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -115,13 +117,15 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} fitContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
@@ -140,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} className="w-max min-w-48">
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -195,7 +199,9 @@ export function MobileDraftTargetTriggers(
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
@@ -275,7 +281,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { fileReferenceExists } from './fileReferenceStat';
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Headers }> = [];
const stubFetchWith = (respond: () => Response) => {
calls.length = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = new Headers(init?.headers);
calls.push({ url, headers });
return respond();
// SAFETY: the stub preserves the fetch signature; every caller in this
// file restores globalThis.fetch in afterEach.
}) as typeof fetch;
};
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('fileReferenceExists directory scoping (issue 3019)', () => {
test('sends the session directory on the stat probe', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
expect(exists).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
});
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
// Directory A resolves the workspace on the server (the browsed
// lastDirectory), so the probe for a path under B is rejected with 400
// and resolves false. The same path probed under B itself must issue a
// fresh request rather than reuse A's cached rejection.
stubFetchWith(() => {
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
if (directoryHint !== '/repo-b') {
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
}
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
});
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
expect(rejectedUnderA).toBe(false);
expect(acceptedUnderB).toBe(true);
expect(calls).toHaveLength(2);
});
test('serves a repeated probe under the same directory from the cache', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
expect(warm).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,80 @@
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};
@@ -167,6 +167,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
return result?.type === 'highlightLines' ? result.lines : null;
};
/** Return an already-tokenized line result without scheduling a worker request. */
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
return cached?.type === 'highlightLines' ? cached.lines : null;
};
/**
* Tokenize `code` with the given resolved TextMate theme and return per-line
* styled runs with offsets — for building CodeMirror decorations that match the
@@ -1,10 +1,43 @@
import { describe, expect, mock, test } from 'bun:test';
type SanitizeAttribute = {
attrName: string;
attrValue: string;
forceKeepAttr?: boolean;
};
class TestAnchorElement {
target = '';
setAttribute(name: string, value: string): void {
if (name === 'target') this.target = value;
}
}
const sanitizeHooks: {
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
afterSanitizeAttributes?: (node: unknown) => void;
} = {};
Object.assign(globalThis, {
window: {},
HTMLAnchorElement: TestAnchorElement,
});
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
sanitizeHooks[name] = hook;
},
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
const anchor = new TestAnchorElement();
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
sanitizeHooks.afterSanitizeAttributes?.(anchor);
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
}),
},
}));
mock.module('./markdown-worker', () => ({
@@ -40,6 +73,21 @@ describe('markdown sanitization', () => {
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
});
test('keeps app and local file links while stripping blocked schemes', () => {
const html = renderMarkdownSync([
'[app](obsidian://open?vault=Notebook)',
'[file](file:///workspace/notes.md)',
'[script](javascript:alert(1))',
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
].join('\n\n'), 'inline');
expect(html).toContain('href="obsidian://open?vault=Notebook"');
expect(html).toContain('href="file:///workspace/notes.md"');
expect(html).not.toContain('href="javascript:alert(1)"');
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
});
});
describe('Markdown images', () => {
@@ -3,6 +3,7 @@ import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isAppLinkUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
@@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => {
sanitizeHookInstalled = true;
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
// DOMPurify's default URI policy strips custom application schemes
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
// stay excluded via isAppLinkUrl and clicks go through confirmation.
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
@@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size,
});
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parseBlock = async (
block: MarkdownBlock,
imageMode: MarkdownImageMode,
): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
@@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
export const renderMarkdownSync = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): string => {
if (!text) return '';
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string;
@@ -567,7 +567,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
return formatted.length > 0 ? formatted : null;
}, [locale, messageCreatedAt, timeFormatPreference]);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
@@ -726,10 +726,13 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
)}
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
>
{/* Positional keys, not part ids: the server echo of a just-sent
message swaps the optimistic part id, and id-based keys would
remount the text subtree (blank frame + height jump). */}
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
<React.Fragment key={`user-subtask-${index}`}>
<UserSubtaskPart part={part} />
</React.Fragment>
);
@@ -737,7 +740,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
if (isShellActionPart(part)) {
return (
<React.Fragment key={part.id ?? `user-shell-${index}`}>
<React.Fragment key={`user-shell-${index}`}>
<UserShellActionPart part={part} />
</React.Fragment>
);
@@ -752,7 +755,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
}
}
return (
<React.Fragment key={part.id ?? `user-text-${index}`}>
<React.Fragment key={`user-text-${index}`}>
<UserTextPart
part={part}
messageId={messageId}
@@ -1702,8 +1705,9 @@ const AssistantMessageBody = React.memo(({
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
const showErrorMessage = Boolean(errorMessage);
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
const shouldShowMessageActions = hasCopyableText;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
const shouldRenderActionsInActivity = isSortedRenderMode;
const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity;
@@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- Assistant markdown treats raw HTML as inert visible text. The final generated
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
CSS into any runtime surface. Safe custom application links go through the
app-link confirmation flow in every supported renderer, including VS Code.
- Final assistant Markdown rendering is independent from image gallery
extraction: gallery presence never changes the chat body. Assistant image
syntax consistently renders as a shared image icon followed by its filename,
@@ -86,7 +87,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- 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.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes 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`).
## "I want to change description for Perplexity" (example recipe)
@@ -1548,7 +1548,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
output,
{
className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1',
maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
followKey: isStreamingBash ? outputString : undefined,
}
);
@@ -72,19 +72,46 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
React.useEffect(() => {
const el = textRef.current;
if (!el) return;
if (!collapsibleUserMessages || isExpanded) return;
const checkTruncation = () => {
if (collapsibleUserMessages && !isExpanded) {
setIsTruncated(el.scrollHeight > el.clientHeight);
}
setIsTruncated(el.scrollHeight > el.clientHeight);
};
checkTruncation();
// A just-sent message mounts while its turn is still settling, so the
// synchronous read can land before the clamp has its final geometry.
// One deferred re-read covers that without waiting for an observer.
const initialFrame = window.requestAnimationFrame(checkTruncation);
// `el` is the clamped box: once line-clamp pins it to two lines its own
// size stops changing, so observing it alone freezes the first
// measurement. Markdown settles after mount (highlighting, late layout),
// and a message measured while still short would never regain the
// expand affordance. The children keep their natural height under the
// clamp, so they are what reports content growth.
const resizeObserver = new ResizeObserver(checkTruncation);
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
const observeChildren = () => {
for (const child of Array.from(el.children)) {
resizeObserver.observe(child);
}
};
observeChildren();
// The renderer swaps subtrees as it settles; re-observe the new children.
const mutationObserver = new MutationObserver(() => {
observeChildren();
checkTruncation();
});
mutationObserver.observe(el, { childList: true, subtree: true });
return () => {
window.cancelAnimationFrame(initialFrame);
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [collapsibleUserMessages, textContent, isExpanded]);
React.useEffect(() => {
@@ -115,10 +142,14 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return;
}
if (collapsibleUserMessages && !isExpanded && isTruncated) {
// Measure at click time instead of trusting the observed flag: whether
// the text is clipped right now is what decides if expanding does
// anything, and the flag can still be catching up on a fresh message.
if (collapsibleUserMessages && !isExpanded && element.scrollHeight > element.clientHeight) {
setIsTruncated(true);
setIsExpanded(true);
}
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, openSkill]);
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
@@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
// Tokenize the whole block in one worker call; rows index into the result.
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
const highlighted = useWorkerHighlightedLines(fullText, language);
const highlightResult = useWorkerHighlightedLines(fullText, language);
const highlighted = highlightResult.lines;
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
@@ -229,15 +229,19 @@ export function WorkingPlaceholder({
return (
<div
// Styled to mirror the turn footer's model row (text-sm,
// muted-foreground/60, no left inset): when the turn completes this row
// disappears and the footer appears in the same visual spot, so the two
// must read as the same line swapping its text.
className={
'flex h-full items-center text-muted-foreground pl-0.5'
'flex h-full items-center text-muted-foreground/60'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="typography-ui-header">
<span className="text-sm">
{hasProviderLogo && providerLogoSrc ? (
<img
src={providerLogoSrc}
@@ -1,22 +1,21 @@
import React from 'react';
/**
* Mobile: clamp an autocomplete popup (anchored above the composer via
* `bottom-full`) so it never rises past the top of the chat area. The chat
* `<main>` starts below the app header in both the Capacitor shell and the
* mobile browser, so its top edge is the correct boundary for both.
* Clamp an autocomplete popup (anchored above the composer via `bottom-full`)
* so it never rises past the top of the chat area. The chat `<main>` starts
* below the app header, so its top edge is the correct boundary.
*
* Re-measures on window resizes and when the native keyboard choreography
* settles (the composer — and therefore the popup's anchor — moves with it).
*
* Returns an inline max-height in px, or undefined when disabled. NOTE: the
* inline value REPLACES any `max-h-*` class (it does not combine) — on mobile
* the popup is allowed to grow all the way to the boundary, unlike the
* desktop design cap.
* Returns an inline max-height only when the available space is smaller than
* the popup's normal CSS height cap. This keeps the desktop cap intact while
* still protecting the header on tall draft composers.
*/
export const useMobileAutocompleteMaxHeight = (
containerRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
normalMaxHeight = 256,
): number | undefined => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(undefined);
@@ -28,16 +27,15 @@ export const useMobileAutocompleteMaxHeight = (
const main = el.closest('main');
if (!main) return;
// Mobile browsers pan the page up to reveal the focused field, so
// <main>'s top can sit ABOVE the visible screen (negative client
// coordinates). The binding boundary is whichever is lower: the
// chat area's top or the visual viewport's top (its offsetTop is
// expressed in the same layout-viewport client coordinates).
// <main>'s top can sit above the visible screen. The binding
// boundary is whichever is lower: the chat area's top or the
// visual viewport's top.
const visualTop = window.visualViewport?.offsetTop ?? 0;
const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop);
// The popup's bottom edge is its anchor (composer top) and does not
// depend on its current height.
const available = el.getBoundingClientRect().bottom - boundaryTop - 8;
const next = Math.max(120, Math.floor(available));
const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8));
const next = available < normalMaxHeight ? available : undefined;
setMaxHeight((prev) => (prev === next ? prev : next));
};
measure();
@@ -45,7 +45,11 @@ exactly as it already does when the context panel opens.
- the user switched it off;
- the runtime is mobile or VS Code;
- the context panel is open for the active directory;
- the context panel is open for the directory the app is effectively on —
looked up through `useEffectiveDirectory` and `normalizeContextPanelDirectoryKey`,
the same key the rail and the panel use. It is deliberately **not** the
directory this panel reports about: a managed Chat reports about none, and
that empty key answered "closed" for a context panel that was plainly open;
- the row cannot fit `WORK_STATUS_MIN_CHAT_WIDTH` of transcript alongside
`WORK_STATUS_PANEL_WIDTH` of panel.
@@ -54,6 +58,11 @@ mode. It remains available on a new-session draft: when the draft targets a
project or pending worktree, the panel uses that directory for project, MCP,
and usage readouts before a session exists.
Managed Chats never render or warm the Project repository section. A Chat draft
also passes no fallback directory to the panel, so an active project's branch
cannot leak into the draft while directory-independent sections remain
available.
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal
when the node attaches, so the measuring effect read `.current`, found nothing
whenever the row mounted after the effect first ran, and only recovered on the
@@ -26,6 +26,8 @@ type Props = {
/** Null on a new-session draft: repository readouts still apply. */
sessionId: string | null;
directory: string | null;
/** Managed Chats have no project repository, even if another project remains active. */
repositoryEnabled?: boolean;
/** Whether the panel should currently occupy space. */
visible: boolean;
/**
@@ -63,7 +65,7 @@ const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
* eat a visible slice of every row's trailing value, and the shadows already
* say there is more to see.
*/
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, overlay = false }) => {
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, repositoryEnabled = true, overlay = false }) => {
const { t } = useI18n();
const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop);
const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
@@ -248,7 +250,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
sessionId={sessionId}
directory={directory}
showSession={sectionVisible('session')}
showRepository={sectionVisible('repository')}
showRepository={repositoryEnabled && sectionVisible('repository')}
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
/>
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
@@ -10,14 +10,16 @@ type PanelState = {
let panelByDirectory: Record<string, PanelState> = {};
let panelEnabled = true;
let effectiveDirectory: string | undefined = '/repo';
mock.module('@/stores/useUIStore', () => ({
useUIStore: (selector: (state: unknown) => unknown) =>
selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }),
normalizeContextPanelDirectoryKey: (value: string) => value,
}));
mock.module('@/lib/pathNormalization', () => ({
normalizePath: (value?: string | null) => value ?? null,
mock.module('@/hooks/useEffectiveDirectory', () => ({
useEffectiveDirectory: () => effectiveDirectory,
}));
const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import(
@@ -88,7 +90,7 @@ const installMinimalDom = () => {
};
};
type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean };
type Args = { isMobile: boolean; isVSCode: boolean };
/**
* Renders the hook with a stand-in row node, attached through the returned
@@ -130,6 +132,7 @@ const renderVisibility = (args: Args, rowWidth: number) => {
beforeEach(() => {
panelByDirectory = {};
panelEnabled = true;
effectiveDirectory = '/repo';
observed = [];
notify = null;
});
@@ -142,7 +145,7 @@ afterEach(() => {
describe('useWorkStatusVisibility', () => {
test('shows the panel when the row can afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -151,7 +154,7 @@ describe('useWorkStatusVisibility', () => {
test('hides the panel when the row cannot afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.visible).toBe(false);
@@ -173,7 +176,6 @@ describe('useWorkStatusVisibility', () => {
const Probe: React.FC = () => {
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
@@ -199,7 +201,7 @@ describe('useWorkStatusVisibility', () => {
// In the app this is the chat area (chat + context panel); here `closest`
// finds nothing, so the hook falls back to the row it was given.
const { rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(observed).toHaveLength(1);
@@ -209,7 +211,7 @@ describe('useWorkStatusVisibility', () => {
test('reacts to a live resize across the threshold', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -228,7 +230,7 @@ describe('useWorkStatusVisibility', () => {
'/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' },
};
const { result, rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(false);
@@ -236,11 +238,23 @@ describe('useWorkStatusVisibility', () => {
teardown();
});
test('yields to the open context panel even when the chat reports on no project', () => {
// A Chat session carries no repository, so the panel describes no
// directory. The context panel is still keyed by the directory the app is
// on, and looking it up under the chat's empty one answered "closed".
panelByDirectory = {
'/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' },
};
const { result, teardown } = renderVisibility({ isMobile: false, isVSCode: false }, REQUIRED);
expect(result.visible).toBe(false);
teardown();
});
test('ignores an open context panel that has no resolvable tab', () => {
// ContextPanel renders nothing in that state, so it displaces nothing.
panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } };
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -264,7 +278,6 @@ describe('useWorkStatusVisibility', () => {
const Probe: React.FC = () => {
const [attached, setAttached] = React.useState(false);
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
@@ -292,7 +305,7 @@ describe('useWorkStatusVisibility', () => {
// there is room for it.
panelEnabled = false;
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED * 2,
);
expect(result.visible).toBe(false);
@@ -302,7 +315,7 @@ describe('useWorkStatusVisibility', () => {
test('reports no fit when the row is too narrow, whatever the switch says', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.fits).toBe(false);
@@ -312,7 +325,7 @@ describe('useWorkStatusVisibility', () => {
test('stays hidden on mobile and in VS Code regardless of width', () => {
const mobile = renderVisibility(
{ directory: '/repo', isMobile: true, isVSCode: false },
{ isMobile: true, isVSCode: false },
REQUIRED * 2,
);
expect(mobile.result.visible).toBe(false);
@@ -320,7 +333,7 @@ describe('useWorkStatusVisibility', () => {
observed = [];
const vscode = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: true },
{ isMobile: false, isVSCode: true },
REQUIRED * 2,
);
expect(vscode.result.visible).toBe(false);
@@ -1,6 +1,6 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { normalizePath } from '@/lib/pathNormalization';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
/**
* Fixed panel width. The panel is not user-resizable: it is an object inside
@@ -23,7 +23,6 @@ export const WORK_STATUS_REQUIRED_ROW_WIDTH =
WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH;
type Options = {
directory: string | null | undefined;
isMobile: boolean;
isVSCode: boolean;
};
@@ -52,12 +51,20 @@ type Result = {
* panel, oscillating forever. The row width is independent of the panel, so it
* is the only stable input.
*/
export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => {
export const useWorkStatusVisibility = ({ isMobile, isVSCode }: Options): Result => {
const [rowNode, setRowNode] = React.useState<HTMLDivElement | null>(null);
const [rowWidth, setRowWidth] = React.useState<number | null>(null);
const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []);
const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]);
// Keyed exactly like the rail and the panel itself: whichever directory the
// app is effectively on, not the directory this panel reports about. A chat
// with no project reports on nothing, and looking the context panel up under
// that empty key answered "closed" while it was plainly open on screen.
const effectiveDirectory = useEffectiveDirectory();
const directoryKey = React.useMemo(
() => (effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''),
[effectiveDirectory],
);
// Mirrors ContextPanel's own derivation: a panel with `isOpen` but no
// resolvable active tab renders nothing, and must not displace this panel.
@@ -1,26 +1,60 @@
import React from 'react';
import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker';
import {
getCachedHighlightedLines,
highlightLinesInWorker,
} from '@/components/chat/markdown/markdown-worker';
// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
// otherwise spawn one highlighter per row. Returns `null` until the first
// result lands (or permanently on failure) — callers render plain text then.
// otherwise spawn one highlighter per row. Cached results are available on the
// first render; cold requests distinguish loading from permanent failure so
// callers can choose whether to reveal their plain-text fallback.
//
// Whole-block tokenization also restores cross-line syntax context (multi-line
// strings / comments) that independent per-line highlighting loses.
export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => {
const [lines, setLines] = React.useState<string[] | null>(null);
export type WorkerHighlightedLinesResult =
| { status: 'loading'; lines: null }
| { status: 'ready'; lines: string[] }
| { status: 'failed'; lines: null };
type HighlightState = WorkerHighlightedLinesResult & {
code: string;
language: string;
};
const getHighlightState = (code: string, language: string): HighlightState => {
const lines = getCachedHighlightedLines(code, language);
return lines
? { status: 'ready', lines, code, language }
: { status: 'loading', lines: null, code, language };
};
export const useWorkerHighlightedLines = (code: string, language: string): WorkerHighlightedLinesResult => {
const normalizedLanguage = (language || 'text').toLowerCase();
const [state, setState] = React.useState<HighlightState>(() => getHighlightState(code, normalizedLanguage));
React.useEffect(() => {
const cached = getCachedHighlightedLines(code, normalizedLanguage);
if (cached) {
setState({ status: 'ready', lines: cached, code, language: normalizedLanguage });
return;
}
setState({ status: 'loading', lines: null, code, language: normalizedLanguage });
let active = true;
setLines(null);
void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => {
if (active) setLines(result);
void highlightLinesInWorker(code, normalizedLanguage).then((lines) => {
if (!active) return;
setState(lines
? { status: 'ready', lines, code, language: normalizedLanguage }
: { status: 'failed', lines: null, code, language: normalizedLanguage });
});
return () => {
active = false;
};
}, [code, language]);
}, [code, normalizedLanguage]);
return lines;
if (state.code !== code || state.language !== normalizedLanguage) {
return getHighlightState(code, normalizedLanguage);
}
return state;
};
@@ -11,6 +11,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useI18n } from '@/lib/i18n';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
type LineRangeBase = {
start: number;
@@ -136,6 +137,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
const normalizedStoreRange = normalizeStoreRange(toStoreRange(normalizedRange));
const code = getCodeForRange(normalizedRange);
const isNewComment = !editingDraftId;
if (editingDraftId) {
updateDraft(target, editingDraftId, {
fileLabel,
@@ -160,6 +162,9 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
}
reset();
if (isNewComment) {
requestAnimationFrame(focusChatInput);
}
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]);
return {
@@ -205,7 +205,10 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
<button
key="close"
type="button"
className={cn(buttonClassName, 'hover:bg-status-error hover:text-status-error-foreground')}
className={cn(
buttonClassName,
'hover:bg-[var(--status-error-background)] hover:text-[var(--status-error-foreground)]',
)}
onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
title={t('header.windowControls.close')}
aria-label={t('header.windowControls.close')}
@@ -5,6 +5,10 @@
* area uses the same paddings/typography as the textarea and the action row
* reuses the footer icon-button styling — so toggling dictation causes no
* vertical shift.
*
* No text appears while recording. The server transcribes the audio once the
* user stops, so the overlay shows the recording state and then Transcribing.
* The only transcript rendered here is the salvage text of a failed dictation.
*/
import React from 'react';
@@ -15,6 +19,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useDictation } from '@/hooks/useDictation';
import { DictationWaveform } from '@/components/dictation/DictationWaveform';
import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -50,25 +55,6 @@ const formatDuration = (seconds: number): string => {
return `${mins}:${String(secs).padStart(2, '0')}`;
};
const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => {
const { currentTheme } = useThemeSystem();
return (
<div
className="h-1.5 w-16 flex-shrink-0 overflow-hidden rounded-full"
style={{ backgroundColor: currentTheme.colors.interactive.border }}
aria-hidden="true"
>
<div
className="h-full rounded-full transition-[width] duration-75"
style={{
width: `${Math.round(Math.min(1, volume) * 100)}%`,
backgroundColor: currentTheme.colors.primary.base,
}}
/>
</div>
);
};
/**
* Polls the dictation status route while the local model is downloading and
* returns the download percent (null while unknown / not downloading).
@@ -162,7 +148,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
const {
status,
partialTranscript,
volume,
subscribeLevel,
duration,
error,
errorReason,
@@ -399,7 +385,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
{/* Measured for the composer-growth report — keep all
transcript/placeholder/error content inside. */}
<div ref={transcriptContentRef}>
{partialTranscript ? (
{status === 'failed' && partialTranscript ? (
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
{partialTranscript}
</p>
@@ -436,8 +422,8 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
style={{ backgroundColor: currentTheme.colors.status.error }}
/>
</span>
<VolumeMeter volume={volume} />
<span className="typography-meta tabular-nums" style={{ color: currentTheme.colors.surface.mutedForeground }}>
<DictationWaveform subscribeLevel={subscribeLevel} className="block h-4 min-w-0 flex-1" />
<span className="typography-meta flex-shrink-0 tabular-nums" style={{ color: currentTheme.colors.surface.mutedForeground }}>
{formatDuration(duration)}
</span>
</>
@@ -446,7 +432,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
) : null}
{/* Same inter-control gap as the composer's right cluster:
gap-x-1 on mobile, md:gap-x-3 on desktop. */}
<div className={cn('ml-auto flex items-center', isMobile ? 'gap-x-1' : 'gap-x-1.5 md:gap-x-3')}>
<div className={cn('ml-auto flex flex-shrink-0 items-center', isMobile ? 'gap-x-1' : 'gap-x-1.5 md:gap-x-3')}>
{status === 'recording' ? (
<>
<button
@@ -0,0 +1,130 @@
/**
* Scrolling microphone level history for the dictation overlay.
*
* Newest sample is at the right edge and the history scrolls left, so the row
* reads as a live recording trace rather than a single level bar. Silence
* renders as a dot, speech as a rounded bar.
*
* Drawn on a canvas fed by a level subscription: the level updates ~12 times a
* second, and routing that through React state would re-render the whole
* dictation overlay at the same rate.
*/
import React from 'react';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { DictationLevelListener } from '@/lib/dictation/use-dictation-audio-source';
interface DictationWaveformProps {
subscribeLevel: (listener: DictationLevelListener) => () => void;
className?: string;
}
const BAR_WIDTH = 2;
const BAR_GAP = 3;
const BAR_PITCH = BAR_WIDTH + BAR_GAP;
/** One sample per bar; ~16 bars/s scrolls at a readable speed. */
const SAMPLE_INTERVAL_MS = 60;
/** Raise quiet speech so normal talking uses most of the height. */
const LEVEL_CURVE = 0.65;
export const DictationWaveform: React.FC<DictationWaveformProps> = ({ subscribeLevel, className }) => {
const { currentTheme } = useThemeSystem();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const barColor = currentTheme.colors.surface.mutedForeground;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
// Peak-hold between samples: a short loud syllable must not be missed
// just because it landed between two frames.
let peakSinceSample = 0;
const unsubscribe = subscribeLevel((level) => {
peakSinceSample = Math.max(peakSinceSample, level);
});
const bars: number[] = [];
let cssWidth = 0;
let cssHeight = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
cssWidth = rect.width;
cssHeight = rect.height;
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const draw = () => {
const capacity = Math.max(1, Math.floor(cssWidth / BAR_PITCH));
while (bars.length > capacity) {
bars.shift();
}
context.clearRect(0, 0, cssWidth, cssHeight);
context.strokeStyle = barColor;
context.fillStyle = barColor;
context.lineWidth = BAR_WIDTH;
context.lineCap = 'round';
const centerY = cssHeight / 2;
const maxHeight = Math.max(BAR_WIDTH, cssHeight);
// Anchor the newest bar to the right edge; older bars trail left.
const rightX = cssWidth - BAR_WIDTH / 2;
for (let i = 0; i < bars.length; i++) {
const x = rightX - (bars.length - 1 - i) * BAR_PITCH;
if (x < BAR_WIDTH / 2) {
continue;
}
const height = BAR_WIDTH + (maxHeight - BAR_WIDTH) * Math.pow(bars[i], LEVEL_CURVE);
// Round caps add BAR_WIDTH/2 past each end of the stroke, so the
// stroke itself is the height minus one cap diameter.
const half = (height - BAR_WIDTH) / 2;
context.beginPath();
if (half < 0.25) {
context.arc(x, centerY, BAR_WIDTH / 2, 0, Math.PI * 2);
context.fill();
} else {
context.moveTo(x, centerY - half);
context.lineTo(x, centerY + half);
context.stroke();
}
}
};
let frame = 0;
let lastSampleAt = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (now - lastSampleAt < SAMPLE_INTERVAL_MS) {
return;
}
lastSampleAt = now;
bars.push(peakSinceSample);
peakSinceSample = 0;
draw();
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
unsubscribe();
};
}, [subscribeLevel, barColor]);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
};
+18 -8
View File
@@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context';
import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore';
import { isChatDirectoryForHome } from '@/lib/chatDirectories';
import { useSync } from '@/sync/use-sync';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
@@ -1052,6 +1054,10 @@ export const Header: React.FC<HeaderProps> = ({
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId);
const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory);
const openDirectory = React.useMemo(() => {
return worktreeDirectory || sessionDirectory || draftDirectory;
@@ -1080,10 +1086,13 @@ export const Header: React.FC<HeaderProps> = ({
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
const isChatContext = isNewSessionDraftOpen
? draftTarget === 'chat'
: isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory);
// Whether the title carries a second line under it. Hoisted because the
// session menu's vertical alignment depends on the same answer.
const showHeaderMetaRow = !workStatusPanelVisible
const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible
&& Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind));
@@ -1423,14 +1432,14 @@ export const Header: React.FC<HeaderProps> = ({
const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
projectId: activeProject?.id ?? null,
directory: isChatContext ? '' : draftDirectory,
projectId: isChatContext ? null : draftProjectId,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
}, [activeProject?.id, activeProject?.path, openDirectory]);
}, [draftDirectory, draftProjectId, isChatContext]);
const handleOpenCurrentMiniChat = React.useCallback(() => {
if (isNewSessionDraftOpen) {
@@ -1443,13 +1452,13 @@ export const Header: React.FC<HeaderProps> = ({
}
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: normalize(openDirectory || activeProject?.path || ''),
directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
}, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]);
}, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
@@ -1901,7 +1910,8 @@ export const Header: React.FC<HeaderProps> = ({
<div
onMouseDown={handleDragStart}
className={cn(
'app-region-drag relative flex h-12 select-none items-center pr-3',
'app-region-drag relative flex h-12 select-none items-center',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
macosHeaderSizeClass
)}
style={webWindowControlsOverlayStyle}
@@ -2053,7 +2063,7 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && currentSession && !currentSession.parentId ? (
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
@@ -45,7 +45,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
export const MainLayout: React.FC = () => {
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const activeSurface = useUIStore((state) => state.activeSurface);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
@@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => {
if (sessionSelected || draftOpened) closeSurfacePages();
});
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages();
if (state.activeSurface !== prev.activeSurface) closeSurfacePages();
});
return () => {
unsubscribeSession();
@@ -195,7 +195,7 @@ export const MainLayout: React.FC = () => {
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
return;
}
@@ -231,7 +231,7 @@ export const MainLayout: React.FC = () => {
window.clearTimeout(timeoutId);
}
};
}, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
}, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
// Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => {
@@ -263,10 +263,10 @@ export const MainLayout: React.FC = () => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') {
if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') {
return null;
}
switch (activeMainTab) {
switch (activeSurface) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
@@ -284,9 +284,9 @@ export const MainLayout: React.FC = () => {
default:
return null;
}
}, [activeMainTab, isMobile, mobileRightSidebarOpen]);
}, [activeSurface, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeMainTab === 'chat';
const isChatActive = activeSurface === 'chat';
return (
<DiffWorkerProvider>
@@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
@@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { t } = useI18n();
const gitDirectories = useGitStore((state) => state.directories);
const isChatContext = useSessionUIStore((state) => (
state.newSessionDraft.open
? state.newSessionDraft.target === 'chat'
: isChatDirectoryPath(state.currentSessionDirectory)
));
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory);
const activeProject = React.useMemo(() => {
if (isChatContext) return null;
if (activeProjectId) {
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
}
return projects[0] ?? null;
}, [activeProjectId, projects]);
}, [activeProjectId, isChatContext, projects]);
const projectRef = React.useMemo(() => {
if (isChatContext && chatsRoot) {
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
}
if (!activeProject) {
return null;
}
@@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{
id: activeProject.id,
path: activeProject.path,
};
}, [activeProject]);
}, [activeProject, chatsRoot, isChatContext]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
if (!activeProject) {
return null;
}
return activeProject.label?.trim()
|| formatDirectoryName(activeProject.path, homeDirectory)
|| activeProject.path;
}, [activeProject, homeDirectory]);
}, [activeProject, homeDirectory, isChatContext, t]);
const canCreateWorktree = React.useMemo(() => {
if (!activeProject) {
@@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon";
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
type MiniChatMode = 'session' | 'draft';
@@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
@@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || '');
const currentDirectoryNormalized = normalizePath(currentDirectory);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized;
const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory);
const directoryLabel = compactPath(openDirectory);
const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || '');
@@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
});
React.useEffect(() => {
if (!openDirectory) return;
if (!openDirectory || isChatContext) return;
void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {});
}, [ensureGitStatus, openDirectory, runtimeApis.git]);
}, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]);
const pathMatchedProject = React.useMemo(() => {
const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null);
@@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
}, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return null;
const project = pathMatchedProject ?? activeProject;
if (!project) return directoryLabel || 'OpenChamber';
const label = project.label?.trim();
if (label) return label;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments.at(-1) ?? project.path;
}, [activeProject, directoryLabel, pathMatchedProject]);
}, [activeProject, directoryLabel, isChatContext, pathMatchedProject]);
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null;
@@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const handleOpenMainApp = React.useCallback(() => {
const payload = currentSessionId
? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' }
: { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId };
: {
mode: 'draft',
directory: isChatContext ? '' : openDirectory || currentDirectory || '',
projectId: isChatContext ? null : draftProjectId,
};
void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload)
.then((result) => {
if (result?.focused === true) {
@@ -249,12 +257,13 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
}
return null;
});
}, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]);
}, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]);
return (
<header
className={cn(
'flex items-center gap-3 bg-background pr-3',
'flex items-center gap-3 bg-background',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)}
@@ -273,7 +282,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{title}
</span>
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{!isChatContext ? <span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<span className="truncate">{projectLabel}</span>
{branchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
@@ -281,7 +290,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate">{branchLabel}</span>
</span>
) : null}
</span>
</span> : null}
</button>
</SessionSwitcherDropdown>
<div className="min-w-0 flex-1" />
@@ -6,10 +6,10 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import {
useAgentsStore,
getConfigDirectory,
type AgentWithExtras,
} from '@/stores/useAgentsStore';
import {
@@ -105,6 +105,9 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
const [reloadToken, setReloadToken] = React.useState(0);
const agentName = agent.name;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
// --- Load the SOURCE permission map (the agent's own config file). ---
React.useEffect(() => {
@@ -113,7 +116,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
setLoadFailed(false);
void (async () => {
try {
const directory = getConfigDirectory();
const directory = settingsDirectory;
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
headers: {
@@ -136,14 +139,14 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName, reloadToken]);
}, [agentName, reloadToken, settingsDirectory]);
// --- Known tool ids for the key list (display only). ---
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
const ids = await opencodeClient.listToolIds({ directory: settingsDirectory });
if (!cancelled && Array.isArray(ids)) {
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
}
@@ -154,7 +157,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName]);
}, [agentName, settingsDirectory]);
// --- Effective rules from the resolved view (read-only hints). ---
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
@@ -4,7 +4,8 @@ import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from './ModelSelector';
import { useI18n } from '@/lib/i18n';
@@ -60,7 +61,6 @@ export const AgentsPage: React.FC = () => {
getAgentByName,
createAgent,
updateAgent,
agents,
agentDraft,
setAgentDraft,
} = useAgentsStore(useShallow((s) => ({
@@ -68,12 +68,15 @@ export const AgentsPage: React.FC = () => {
getAgentByName: s.getAgentByName,
createAgent: s.createAgent,
updateAgent: s.updateAgent,
agents: s.agents,
agentDraft: s.agentDraft,
setAgentDraft: s.setAgentDraft,
})));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName, settingsDirectory) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
const [draftName, setDraftName] = React.useState('');
@@ -232,12 +235,12 @@ export const AgentsPage: React.FC = () => {
let result: AgentMutationResult;
if (isNewAgent) {
result = await createAgent(config);
result = await createAgent(config, settingsDirectory);
if (result.ok) {
setAgentDraft(null); // Clear draft after successful creation
}
} else {
result = await updateAgent(agentName, config);
result = await updateAgent(agentName, config, settingsDirectory);
}
if (result.ok) {
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
@@ -113,7 +114,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const {
selectedAgentName,
agents,
setSelectedAgent,
setAgentDraft,
createAgent,
@@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents,
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
agents: s.agents,
setSelectedAgent: s.setSelectedAgent,
setAgentDraft: s.setAgentDraft,
createAgent: s.createAgent,
@@ -129,9 +128,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents: s.loadAgents,
})));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
void loadAgents(settingsDirectory);
}, [loadAgents, settingsDirectory]);
const bgClass = 'bg-background';
@@ -183,7 +187,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setIsConfirmActionPending(true);
try {
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope);
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory);
if (result.ok) {
if (result.requiresManualRestart) {
@@ -291,11 +295,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
disable: renameExt.disable,
scope: renameExt.scope,
});
}, settingsDirectory);
if (createResult.ok) {
// Delete old agent
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope);
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory);
if (deleteResult.ok) {
if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
@@ -3,7 +3,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectCommandsForDirectory, useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector';
@@ -33,7 +34,6 @@ export const CommandsPage: React.FC = () => {
getCommandByName,
createCommand,
updateCommand,
commands,
commandDraft,
setCommandDraft,
} = useCommandsStore(useShallow((s) => ({
@@ -41,12 +41,15 @@ export const CommandsPage: React.FC = () => {
getCommandByName: s.getCommandByName,
createCommand: s.createCommand,
updateCommand: s.updateCommand,
commands: s.commands,
commandDraft: s.commandDraft,
setCommandDraft: s.setCommandDraft,
})));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName, settingsDirectory) : null;
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
const [draftName, setDraftName] = React.useState('');
@@ -162,12 +165,12 @@ export const CommandsPage: React.FC = () => {
let success: boolean;
if (isNewCommand) {
success = await createCommand(config);
success = await createCommand(config, settingsDirectory);
if (success) {
setCommandDraft(null);
}
} else {
success = await updateCommand(commandName, config);
success = await updateCommand(commandName, config, settingsDirectory);
}
if (success) {
@@ -18,8 +18,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
@@ -43,7 +44,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const {
selectedCommandName,
commands,
setSelectedCommand,
setCommandDraft,
createCommand,
@@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
loadCommands,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
commands: s.commands,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
const skills = useSkillsStore((s) => s.skills);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
loadCommands();
loadSkills();
}, [loadCommands, loadSkills]);
void loadCommands(settingsDirectory);
void loadSkills(settingsDirectory);
}, [loadCommands, loadSkills, settingsDirectory]);
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
const commandOnlyItems = React.useMemo(
@@ -131,7 +134,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}
setIsConfirmActionPending(true);
const success = await deleteCommand(confirmActionCommand.name);
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
if (success) {
if (confirmActionType === 'delete') {
@@ -204,11 +207,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
});
}, settingsDirectory);
if (success) {
// Delete old command
const deleteSuccess = await deleteCommand(renameDialogCommand.name);
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
if (deleteSuccess) {
toast.success(`Command renamed to "${sanitizedName}"`);
setSelectedCommand(sanitizedName);
@@ -1,74 +0,0 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import type { IconName } from '@/components/icon/icons';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type ComingSoonMessenger = {
id: 'discord' | 'telegram';
icon: IconName;
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
};
const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [
{
id: 'discord',
icon: 'discord-fill',
brandClassName: 'text-[#5865F2]',
nameKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
},
{
id: 'telegram',
icon: 'telegram-fill',
brandClassName: 'text-[#2AABEE]',
nameKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
},
] as const;
/**
* Non-interactive Discord/Telegram placeholders — same card chrome as live
* integrations, greyed out, with a Coming soon badge and no expandable body.
*/
export const ComingSoonMessengersSection: React.FC = () => {
const { t } = useI18n();
return (
<SettingsSection
title={t('settings.integrations.messengers.title')}
info={t('settings.integrations.messengers.info')}
divider={false}
settingsItem="integrations.messengers"
contentClassName="space-y-3"
>
{COMING_SOON_MESSENGERS.map((messenger) => (
<div
key={messenger.id}
data-settings-item={`integrations.messengers.${messenger.id}`}
aria-disabled="true"
className={cn(
'flex min-w-0 items-center gap-3 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3',
'pointer-events-none opacity-60',
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={messenger.icon} className={cn('size-5', messenger.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(messenger.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(messenger.descriptionKey)}
</p>
</div>
<span className="max-w-36 shrink-0 truncate rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('settings.common.state.comingSoon')}
</span>
</div>
))}
</SettingsSection>
);
};
@@ -1,5 +1,7 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
@@ -17,7 +19,17 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={t('settings.page.integrations.description')}
description={(
<div className="space-y-3">
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
</div>
)}
showSaveStatus={false}
>
<ThirdPartyIntegrationsSection
@@ -13,7 +13,6 @@ import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
@@ -330,11 +329,7 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
{plugin.providerId === 'command-code' ? (
<ProviderLogo providerId={plugin.providerId} className="size-5" />
) : (
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
)}
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
@@ -131,11 +131,6 @@ describe('third-party plugin catalog helpers', () => {
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
@@ -25,16 +25,6 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
providerId: 'command-code',
icon: 'command-code',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
@@ -7,6 +7,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
selectMcpServersForDirectory,
useMcpConfigStore,
envRecordToArray,
type McpDraft,
@@ -19,7 +20,7 @@ import {
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -556,7 +557,6 @@ export const McpPage: React.FC = () => {
);
const {
selectedMcpName,
mcpServers,
mcpDraft,
setMcpDraft,
setSelectedMcp,
@@ -566,7 +566,6 @@ export const McpPage: React.FC = () => {
deleteMcp,
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
@@ -576,10 +575,12 @@ export const McpPage: React.FC = () => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const currentDirectory = useSettingsDirectory();
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
@@ -588,7 +589,8 @@ export const McpPage: React.FC = () => {
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
// ── form state ──
@@ -908,7 +910,7 @@ export const McpPage: React.FC = () => {
};
setIsSaving(true);
try {
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -940,7 +942,7 @@ export const McpPage: React.FC = () => {
const handleDelete = async () => {
if (!selectedMcpName) return;
setIsDeleting(true);
const result = await deleteMcp(selectedMcpName);
const result = await deleteMcp(selectedMcpName, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -967,7 +969,7 @@ export const McpPage: React.FC = () => {
} else {
await connectMcp(selectedMcpName, currentDirectory);
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (nextStatus?.status === 'connected') {
toast.success(t('settings.mcp.page.toast.connected'));
} else if (nextStatus?.status === 'needs_auth') {
@@ -1029,7 +1031,7 @@ export const McpPage: React.FC = () => {
const actionKey = runtimeActionKey;
let queuedStateKey: string | null = null;
try {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
// One implementation for every surface that can authorise; the page
@@ -1237,7 +1239,7 @@ export const McpPage: React.FC = () => {
void (async () => {
authPollAttemptsRef.current += 1;
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (!nextStatus) {
return;
@@ -8,10 +8,10 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
@@ -65,9 +65,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore(useShallow((s) => ({
mcpServers: s.mcpServers,
selectedMcpName: s.selectedMcpName,
setSelectedMcp: s.setSelectedMcp,
setMcpDraft: s.setMcpDraft,
@@ -75,8 +74,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
@@ -96,8 +98,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
);
React.useEffect(() => {
void loadMcpConfigs();
}, [loadMcpConfigs]);
void loadMcpConfigs({ directory: settingsDirectory });
}, [loadMcpConfigs, settingsDirectory]);
const handleRefresh = React.useCallback(() => {
if (isRefreshingStatus) return;
@@ -106,17 +108,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
Promise.all([
refreshStatus({ directory: currentDirectory, silent: true }),
refreshStatus({ directory: settingsDirectory, silent: true }),
minSpinPromise,
]).then(() => {
const error = getErrorForDirectory(currentDirectory);
const error = getErrorForDirectory(settingsDirectory);
if (error) {
toast.error(error);
}
}).finally(() => {
setIsRefreshingStatus(false);
});
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
}, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]);
const handleCreateNew = () => {
const baseName = 'new-mcp-server';
@@ -151,7 +153,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result = await deleteMcp(deleteTarget.name);
const result = await deleteMcp(deleteTarget.name, settingsDirectory);
if (result.ok) {
if (result.reloadFailed) {
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
@@ -0,0 +1,48 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
/**
* Security section for application deep links (obsidian://, notion://, ...)
* that the user chose to always allow from chat. Removing a scheme restores
* the confirmation dialog for it.
*/
export const AppLinkSecuritySettings: React.FC = () => {
const { t } = useI18n();
const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes);
const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme);
return (
<SettingsSection
title={t('settings.openchamber.appLinks.title')}
description={t('settings.openchamber.appLinks.info')}
>
<div className="space-y-1" data-settings-item="general.app-links">
{trustedSchemes.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{t('settings.openchamber.appLinks.empty')}
</p>
) : (
trustedSchemes.map((scheme) => (
<div key={scheme} className="flex items-center justify-between gap-2 py-0.5">
<span className="min-w-0 truncate font-mono text-[13px]">{`${scheme}://`}</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => removeTrustedScheme(scheme)}
className="!font-normal text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.appLinks.removeAria', { scheme: `${scheme}://` })}
>
{t('settings.common.actions.delete')}
</Button>
</div>
))
)}
</div>
</SettingsSection>
);
};
@@ -17,10 +17,13 @@ import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'
import { updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
const getDisplayModel = (
storedModel: string | undefined
@@ -42,6 +45,20 @@ export const DefaultsSettings: React.FC = () => {
const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel);
const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant);
const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent);
// A default describes new sessions. Applying it to the open chat is a
// convenience, not the point, so it stops where the chat carries a choice the
// user made for it — the same pair of signals ModelControls restores from
// (`shouldPreserveManualModelOverride`).
const selectionIsManual = useConfigStore((state) => state.selectionSource === 'manual');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection);
const getSessionAgentSelection = useSelectionStore((state) => state.getSessionAgentSelection);
const chatHasOwnModel = Boolean(
selectionIsManual && currentSessionId && getSessionModelSelection(currentSessionId),
);
const chatHasOwnAgent = Boolean(
selectionIsManual && currentSessionId && getSessionAgentSelection(currentSessionId),
);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers);
@@ -147,14 +164,17 @@ export const DefaultsSettings: React.FC = () => {
setDefaultModel(newValue);
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
setSettingsDefaultModel(newValue);
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
if (!chatHasOwnModel) {
setCurrentVariant(undefined);
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
}
}
}
@@ -172,7 +192,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default model:', error);
}
},
[providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
[chatHasOwnModel, providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
);
const DEFAULT_VARIANT_VALUE = '__default__';
@@ -189,7 +209,9 @@ export const DefaultsSettings: React.FC = () => {
const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : variant || undefined;
setDefaultVariant(newValue);
setSettingsDefaultVariant(newValue);
setCurrentVariant(newValue);
if (!chatHasOwnModel) {
setCurrentVariant(newValue);
}
try {
await updateDesktopSettings({ defaultVariant: newValue ?? '' });
@@ -197,7 +219,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default variant:', error);
}
},
[setCurrentVariant, setSettingsDefaultVariant]
[chatHasOwnModel, setCurrentVariant, setSettingsDefaultVariant]
);
const handleAgentChange = React.useCallback(
@@ -206,7 +228,7 @@ export const DefaultsSettings: React.FC = () => {
setDefaultAgent(newValue);
setSettingsDefaultAgent(newValue);
if (agentName) {
if (agentName && !chatHasOwnAgent) {
setAgent(agentName);
}
@@ -216,7 +238,7 @@ export const DefaultsSettings: React.FC = () => {
console.warn('Failed to save default agent:', error);
}
},
[setAgent, setSettingsDefaultAgent]
[chatHasOwnAgent, setAgent, setSettingsDefaultAgent]
);
const handleSmallModelUseDefaultChange = React.useCallback(
@@ -315,12 +337,14 @@ export const DefaultsSettings: React.FC = () => {
if (!supportsVariants && defaultVariant) {
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
if (!chatHasOwnModel) {
setCurrentVariant(undefined);
}
updateDesktopSettings({ defaultVariant: '' }).catch(() => {
// best effort
});
}
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
}, [chatHasOwnModel, defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
if (isLoading) {
return null;
@@ -390,6 +414,7 @@ export const DefaultsSettings: React.FC = () => {
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
filter={(agent) => isPrimaryMode(agent.mode)}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { PasskeySettings } from './PasskeySettings';
import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<SessionRetentionSettings />
<AppLinkSecuritySettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
</SettingsPageLayout>
@@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => {
<>
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
<AppLinkSecuritySettings />
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[
@@ -3,6 +3,15 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
SettingsFieldRow,
SETTINGS_CUSTOM_TRIGGER_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useConfigStore } from '@/stores/useConfigStore';
import { modelVariantNames } from '@/lib/modelVariants';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
@@ -19,9 +28,14 @@ type ProjectIdentityFieldsProps = {
form: ProjectIdentityFormState;
};
const NO_VARIANT_VALUE = '__default__';
const formatVariantLabel = (variant: string): string => variant.charAt(0).toUpperCase() + variant.slice(1);
export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ form }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const providers = useConfigStore((state) => state.providers);
const {
name,
setName,
@@ -32,7 +46,9 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
iconBackground,
setIconBackground,
parsedDefaultModel,
defaultVariant,
handleDefaultModelChange,
handleDefaultVariantChange,
isUploadingIcon,
isRemovingCustomIcon,
isDiscoveringIcon,
@@ -53,6 +69,15 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
project,
} = form;
const availableVariants = React.useMemo(() => {
const { providerId, modelId } = parsedDefaultModel;
if (!providerId || !modelId) return [];
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((entry) => entry.id === modelId);
return modelVariantNames(model);
}, [parsedDefaultModel, providers]);
if (!project) {
return null;
}
@@ -75,16 +100,51 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.defaultModel')}
info={t('settings.projects.page.field.defaultModelDescription')}
settingsItem="projects.default-model"
title={t('settings.projects.page.section.chatDefaults')}
info={t('settings.projects.page.section.chatDefaultsDescription')}
contentClassName="space-y-0"
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
/>
<SettingsFieldRow
settingsItem="projects.default-model"
label={t('settings.projects.page.field.projectModel')}
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
{availableVariants.length > 0 ? (
<SettingsFieldRow
settingsItem="projects.default-thinking"
label={t('settings.projects.page.field.projectThinking')}
>
<Select
value={defaultVariant ?? NO_VARIANT_VALUE}
onValueChange={(value) => handleDefaultVariantChange(value === NO_VARIANT_VALUE ? undefined : value)}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
aria-label={t('settings.projects.page.field.projectThinking')}
>
<SelectValue>
{defaultVariant
? formatVariantLabel(defaultVariant)
: t('settings.projects.page.option.thinkingDefault')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VARIANT_VALUE}>{t('settings.projects.page.option.thinkingDefault')}</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>{formatVariantLabel(variant)}</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
) : null}
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
@@ -37,6 +37,7 @@ export const ProjectsPage: React.FC = () => {
color: data.color,
iconBackground: data.iconBackground,
defaultModel: data.defaultModel ?? null,
defaultVariant: data.defaultVariant ?? null,
});
}, [selectedProject, updateProjectMeta]);
@@ -24,11 +24,12 @@ export type ProjectIdentitySaveData = {
color: string | null;
iconBackground: string | null;
defaultModel: string | null;
defaultVariant: string | null;
};
type EditableProject = Pick<
ProjectEntry,
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'iconImage' | 'path'
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'defaultVariant' | 'iconImage' | 'path'
>;
export const useProjectIdentityForm = (project: EditableProject | null) => {
@@ -45,6 +46,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
const [color, setColor] = React.useState<string | null>(null);
const [iconBackground, setIconBackground] = React.useState<string | null>(null);
const [defaultModel, setDefaultModel] = React.useState<string | undefined>(undefined);
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>(undefined);
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
@@ -73,6 +75,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
setColor(null);
setIconBackground(null);
setDefaultModel(undefined);
setDefaultVariant(undefined);
return;
}
setName(project.label ?? '');
@@ -80,6 +83,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
setColor(project.color ?? null);
setIconBackground(project.iconBackground ?? null);
setDefaultModel(project.defaultModel);
setDefaultVariant(project.defaultVariant);
setPendingRemoveImageIcon(false);
clearPendingUploadIcon();
setPreviewImageFailed(false);
@@ -110,12 +114,20 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|| color !== (project?.color ?? null)
|| iconBackground !== (project?.iconBackground ?? null)
|| (defaultModel ?? undefined) !== (project?.defaultModel ?? undefined)
|| (defaultVariant ?? undefined) !== (project?.defaultVariant ?? undefined)
|| pendingRemoveImageIcon
|| Boolean(pendingUploadIconFile)
);
const handleDefaultModelChange = React.useCallback((providerId: string, modelId: string) => {
setDefaultModel(providerId && modelId ? `${providerId}/${modelId}` : undefined);
// Variants belong to a model. Carrying the old one over would pin a name
// the new model may not have.
setDefaultVariant(undefined);
}, []);
const handleDefaultVariantChange = React.useCallback((variant: string | undefined) => {
setDefaultVariant(variant);
}, []);
const handleUploadIcon = React.useCallback((file: File | null) => {
@@ -232,11 +244,13 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
color,
iconBackground: normalizeProjectIconBackground(willRemoveImageIcon ? null : iconBackground),
defaultModel: defaultModel ?? null,
defaultVariant: defaultModel ? defaultVariant ?? null : null,
};
}, [
clearPendingUploadIcon,
color,
defaultModel,
defaultVariant,
icon,
iconBackground,
name,
@@ -262,8 +276,10 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
iconBackground,
setIconBackground,
defaultModel,
defaultVariant,
parsedDefaultModel,
handleDefaultModelChange,
handleDefaultVariantChange,
isUploadingIcon,
isRemovingCustomIcon,
isDiscoveringIcon,
@@ -10,9 +10,11 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
CUSTOM_PROVIDER_PROTOCOLS,
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
@@ -161,6 +163,31 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.protocol.label')}
info={t('settings.providers.page.custom.field.protocol.info')}
>
<Select
value={form.protocol}
onValueChange={(protocol) => {
if (!(protocol in CUSTOM_PROVIDER_PROTOCOLS)) {
return;
}
setForm((prev) => ({ ...prev, protocol }));
}}
disabled={busy}
>
<SelectTrigger aria-label={t('settings.providers.page.custom.field.protocol.label')} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai-chat">{t('settings.providers.page.custom.field.protocol.openaiChat')}</SelectItem>
<SelectItem value="openai-responses">{t('settings.providers.page.custom.field.protocol.openaiResponses')}</SelectItem>
<SelectItem value="anthropic-messages">{t('settings.providers.page.custom.field.protocol.anthropicMessages')}</SelectItem>
</SelectContent>
</Select>
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
@@ -4,7 +4,8 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -144,7 +145,10 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
export const ProvidersPage: React.FC = () => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
@@ -341,7 +345,8 @@ export const ProvidersPage: React.FC = () => {
try {
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
// not local auth/source-file provenance used by this settings UI.
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
const query = settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : '';
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -370,7 +375,7 @@ export const ProvidersPage: React.FC = () => {
return () => {
cancelled = true;
};
}, [selectedProviderId, t]);
}, [selectedProviderId, settingsDirectory, t]);
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
@@ -431,7 +436,7 @@ export const ProvidersPage: React.FC = () => {
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
const response = await runtimeFetch(`/api/provider${settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : ''}`, {
method: 'PUT',
headers: {
Accept: 'application/json',
@@ -484,10 +489,13 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const response = await runtimeFetch(
`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all${settingsDirectory ? `&directory=${encodeURIComponent(settingsDirectory)}` : ''}`,
{
method: 'DELETE',
headers: { Accept: 'application/json' },
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
@@ -2,7 +2,8 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
@@ -40,16 +41,28 @@ interface ProvidersSidebarProps {
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
const directory = React.useMemo(() => {
if (settingsDirectory) return settingsDirectory;
// tie refresh to active project changes (directory is stored in the client)
void activeProjectId;
return getCurrentDirectory();
}, [activeProjectId]);
}, [activeProjectId, settingsDirectory]);
// The app only loads providers for the project it is on; Settings has to ask
// for the one it is looking at.
const loadProviders = useConfigStore((state) => state.loadProviders);
React.useEffect(() => {
if (!settingsDirectory) return;
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
}, [loadProviders, settingsDirectory]);
React.useEffect(() => {
if (providers.length === 0) {
@@ -16,6 +16,7 @@ const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
protocol: 'openai-chat',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
@@ -96,6 +97,16 @@ describe('validateCustomProvider', () => {
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
});
test('uses the selected OpenCode provider adapter', () => {
const result = validateCustomProvider({
form: baseForm({ protocol: 'openai-responses' }),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.config.npm).toBe('@ai-sdk/openai');
});
test('rejects missing credentials', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: ' ' }),
@@ -300,10 +311,21 @@ describe('provider edit helpers', () => {
expect(state.name).toBe('Campus LLM');
expect(state.baseURL).toBe('https://llm.example.edu/v1');
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
expect(state.protocol).toBe('openai-chat');
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
});
test('prefills the protocol from a custom provider model', () => {
const state = providerToCustomFormState({
id: 'responses-api',
options: { baseURL: 'https://api.example.com/v1' },
models: [{ id: 'gpt', name: 'GPT', api: { npm: '@ai-sdk/openai' } }],
});
expect(state.protocol).toBe('openai-responses');
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
@@ -1,10 +1,16 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Custom provider form helpers.
* Mirrors OpenCode web UI validation and request construction so a provider
* can be defined from Settings without code changes.
*/
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_PROTOCOLS = {
'openai-chat': '@ai-sdk/openai-compatible',
'openai-responses': '@ai-sdk/openai',
'anthropic-messages': '@ai-sdk/anthropic',
} as const;
export type CustomProviderProtocol = keyof typeof CUSTOM_PROVIDER_PROTOCOLS;
export type CustomProviderNpm = (typeof CUSTOM_PROVIDER_PROTOCOLS)[CustomProviderProtocol];
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
@@ -30,6 +36,7 @@ export type HeaderRow = {
export type CustomProviderFormState = {
providerID: string;
name: string;
protocol: CustomProviderProtocol;
baseURL: string;
apiKey: string;
models: ModelRow[];
@@ -54,7 +61,7 @@ export type HeaderFieldErrors = {
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
npm: CustomProviderNpm;
name: string;
env?: string[];
options: {
@@ -120,12 +127,24 @@ export const createHeaderRow = (): HeaderRow => ({
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
protocol: 'openai-chat',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
function protocolFromNpm(npm: string | undefined): CustomProviderProtocol {
switch (npm) {
case '@ai-sdk/openai':
return 'openai-responses';
case '@ai-sdk/anthropic':
return 'anthropic-messages';
default:
return 'openai-chat';
}
}
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
@@ -159,7 +178,7 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
const api = 'api' in model && model.api && typeof model.api === 'object'
? model.api as { npm?: unknown }
: null;
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
return typeof api?.npm === 'string' && new Set<string>(Object.values(CUSTOM_PROVIDER_PROTOCOLS)).has(api.npm);
});
}
@@ -238,9 +257,14 @@ export function providerToCustomFormState(provider: ProviderLikeForCustomForm):
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
: undefined;
const modelWithApi = modelEntries.find(
(model): model is { id?: string; name?: string; api?: { npm?: string } } => 'api' in model,
);
return {
providerID: provider.id,
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
protocol: protocolFromNpm(modelWithApi?.api?.npm),
baseURL,
apiKey: envName ? `{env:${envName}}` : '',
models,
@@ -360,7 +384,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
npm: CUSTOM_PROVIDER_PROTOCOLS[input.form.protocol],
name,
...(env ? { env: [env] } : {}),
options: {
@@ -23,7 +23,9 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
import {
SettingsSection,
SettingsGroupTitle,
SettingsChipGroup,
SETTINGS_PAGE_TITLE_CLASS,
SETTINGS_SECTION_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
@@ -150,6 +152,58 @@ const isConnectingPhase = (phase?: string): boolean => {
return Boolean(phase && CONNECTING_PHASES.has(phase));
};
// The backend reports 13 lifecycle phases. People only need to know which of
// three situations they are in; the phase stays as the secondary detail line.
type InstanceState = 'idle' | 'connecting' | 'ready' | 'error';
const instanceState = (phase?: string): InstanceState => {
if (phase === 'ready') return 'ready';
if (phase === 'error') return 'error';
if (phase === 'degraded' || isConnectingPhase(phase)) return 'connecting';
return 'idle';
};
const instanceStateLabelKey = (state: InstanceState): I18nKey => {
switch (state) {
case 'ready':
return 'settings.remoteInstances.page.state.ready';
case 'connecting':
return 'settings.remoteInstances.page.state.connecting';
case 'error':
return 'settings.remoteInstances.page.state.problem';
default:
return 'settings.remoteInstances.page.state.notConnected';
}
};
// Known backend failures that the user can act on from here. Everything else
// falls back to the raw detail plus the logs button.
type ErrorRemedy = 'uiPassword' | 'localPort' | 'noRuntime' | 'noOpencode' | 'externalPort' | null;
const errorRemedy = (detail?: string): ErrorRemedy => {
const text = (detail || '').toLowerCase();
if (!text) return null;
if (text.includes('ui authentication') || text.includes('ui password')) return 'uiPassword';
if (text.includes('already in use') || text.includes('eaddrinuse')) return 'localPort';
if (text.includes('neither bun nor npm')) return 'noRuntime';
if (text.includes('opencode cli is not installed')) return 'noOpencode';
if (text.includes('requires a ui password')) return 'uiPassword';
if (text.includes('preferred remote openchamber port')) return 'externalPort';
return null;
};
// Remedies the user resolves on the remote machine: explain, do not offer a button.
const REMEDY_HINT_KEYS = {
noRuntime: 'settings.remoteInstances.page.error.hint.noRuntime',
noOpencode: 'settings.remoteInstances.page.error.hint.noOpencode',
} satisfies Record<string, I18nKey>;
const remedyHintKey = (remedy: ErrorRemedy): I18nKey | null => {
if (remedy === 'noRuntime') return REMEDY_HINT_KEYS.noRuntime;
if (remedy === 'noOpencode') return REMEDY_HINT_KEYS.noOpencode;
return null;
};
const phaseDotClass = (phase?: string): string => {
if (phase === 'ready') {
return 'bg-[var(--status-success)] animate-pulse';
@@ -462,8 +516,11 @@ export const RemoteInstancesPage: React.FC = () => {
const [transportOptions, setTransportOptions] = React.useState<{ localUrl: string | null; lanUrl: string | null; relayAvailable: boolean } | null>(null);
const revokedClientCount = React.useMemo(() => remoteClients.filter((client) => Boolean(client.revokedAt)).length, [remoteClients]);
const [sshAddDialogOpen, setSshAddDialogOpen] = React.useState(false);
const [sshCommandDraft, setSshCommandDraft] = React.useState('ssh user@example.com');
const [sshAddMode, setSshAddMode] = React.useState<'saved' | 'manual'>('saved');
const [sshHostSearch, setSshHostSearch] = React.useState('');
const [sshCommandDraft, setSshCommandDraft] = React.useState('');
const [sshNameDraft, setSshNameDraft] = React.useState('');
const [advancedOpen, setAdvancedOpen] = React.useState(false);
React.useEffect(() => {
void load();
@@ -730,7 +787,7 @@ export const RemoteInstancesPage: React.FC = () => {
await createFromCommand(id, command, sshNameDraft.trim() || t('settings.remoteInstances.sidebar.newSshInstanceName'));
setSelectedId(id);
setSshAddDialogOpen(false);
setSshCommandDraft('ssh user@example.com');
setSshCommandDraft('');
setSshNameDraft('');
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
} catch (error) {
@@ -740,6 +797,12 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [createFromCommand, setSelectedId, sshCommandDraft, sshNameDraft, t]);
const openSshAddDialog = React.useCallback(() => {
setSshHostSearch('');
setSshAddMode(importCandidates.length > 0 ? 'saved' : 'manual');
setSshAddDialogOpen(true);
}, [importCandidates.length]);
const setDefaultDirectHost = React.useCallback(async (id: string) => {
await persistDirectHosts(directHosts, id);
}, [directHosts, persistDirectHosts]);
@@ -1000,6 +1063,11 @@ export const RemoteInstancesPage: React.FC = () => {
setDraft(selectedInstance);
}, [selectedInstance]);
// Every instance opens on the simple view; advanced stays a deliberate choice.
React.useEffect(() => {
setAdvancedOpen(false);
}, [selectedId]);
React.useEffect(() => {
if (!selectedId) {
return;
@@ -1064,6 +1132,9 @@ export const RemoteInstancesPage: React.FC = () => {
const canDisconnect = isReady || isBusy;
const statusAgeMs = status ? Math.max(0, clockMs - status.updatedAtMs) : 0;
const reconnectAppearsStuck = isReconnecting && statusAgeMs > 12_000;
const currentState = instanceState(statusPhase);
const currentRemedy = currentState === 'error' ? errorRemedy(status?.detail) : null;
const currentRemedyHintKey = remedyHintKey(currentRemedy);
const hasChanges = React.useMemo(() => {
if (!draft || !selectedInstance) return false;
@@ -1083,6 +1154,25 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
// "Already running" cannot pick a port on its own; catching it here keeps
// the failure in the form instead of surfacing it mid-connect.
if (normalized.remoteOpenchamber.mode === 'external' && !normalized.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
normalized.remoteOpenchamber.mode === 'managed' &&
normalized.remoteOpenchamber.bindHost === '0.0.0.0' &&
!normalized.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
if (normalized.localForward.bindHost === '0.0.0.0') {
const allow = window.confirm(
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
@@ -1154,6 +1244,7 @@ export const RemoteInstancesPage: React.FC = () => {
const handleImportCandidate = React.useCallback(
(host: string, pattern: boolean) => {
setSshAddDialogOpen(false);
if (pattern) {
setPatternHost(host);
setPatternDestination(suggestConcreteHost(host));
@@ -1164,6 +1255,25 @@ export const RemoteInstancesPage: React.FC = () => {
[createImportedInstance],
);
const filteredImportCandidates = React.useMemo(() => {
const query = sshHostSearch.trim().toLowerCase();
if (!query) return importCandidates;
return importCandidates.filter((candidate) => {
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
});
}, [importCandidates, sshHostSearch]);
// Opening a ready instance means pointing this window at the forwarded local
// URL — the same navigation the host switcher performs after its own connect.
const openInstanceUrl = React.useCallback((localUrl?: string) => {
const target = (localUrl || '').trim();
if (!target) {
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
return;
}
navigateToUrl(target);
}, [t]);
const handlePatternCreate = React.useCallback(async () => {
const host = patternHost;
const destination = patternDestination.trim();
@@ -1216,6 +1326,44 @@ export const RemoteInstancesPage: React.FC = () => {
}
}, [connect, selectedInstance, t, upsertInstance]);
const uiPasswordRef = React.useRef<HTMLInputElement | null>(null);
const remotePortRef = React.useRef<HTMLDivElement | null>(null);
// Turn a reported failure into the one action that resolves it, instead of
// leaving the raw backend sentence as the whole answer.
const applyErrorRemedy = React.useCallback(async (remedy: ErrorRemedy) => {
if (!selectedInstance) return;
if (remedy === 'localPort') {
const nextInstance: DesktopSshInstance = {
...selectedInstance,
localForward: {
...selectedInstance.localForward,
preferredLocalPort: randomPort(),
},
};
try {
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
} catch (error) {
toast.error(t('settings.remoteInstances.page.toast.connectFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
return;
}
setAdvancedOpen(true);
window.setTimeout(() => {
if (remedy === 'uiPassword') {
uiPasswordRef.current?.focus();
return;
}
remotePortRef.current?.scrollIntoView({ block: 'center' });
}, 0);
}, [connect, selectedInstance, t, upsertInstance]);
const readLogsForInstance = React.useCallback(async (id: string) => {
const lines = await desktopSshLogs(id, 600);
return lines.map((line) => formatLogLine(line));
@@ -1321,6 +1469,24 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
if (!canDisconnect && draft.remoteOpenchamber.mode === 'external' && !draft.remoteOpenchamber.preferredPort) {
toast.error(t('settings.remoteInstances.page.validation.externalPortRequired'));
setAdvancedOpen(true);
return;
}
if (
!canDisconnect &&
draft.remoteOpenchamber.mode === 'managed' &&
draft.remoteOpenchamber.bindHost === '0.0.0.0' &&
!draft.auth.openchamberPassword?.value?.trim()
) {
toast.error(t('settings.remoteInstances.page.validation.remoteLanNeedsPassword'));
setAdvancedOpen(true);
window.setTimeout(() => uiPasswordRef.current?.focus(), 0);
return;
}
setIsPrimaryActionPending(true);
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
void operation
@@ -1774,7 +1940,7 @@ export const RemoteInstancesPage: React.FC = () => {
title={t('settings.remoteInstances.sidebar.title')}
description={t('settings.remoteInstances.sidebar.total', { count: instances.length })}
headerAction={(
<Button type="button" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(true)}>
<Button type="button" size="xs" className="!font-normal" onClick={openSshAddDialog}>
<Icon name="add" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.sidebar.actions.addSshInstance')}
</Button>
@@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-2.5"
>
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.state.loadingInstances')}</p>
) : instances.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
<p className="typography-meta text-muted-foreground">
{importCandidates.length === 1
? t('settings.remoteInstances.page.empty.noInstancesWithOneImport')
: importCandidates.length > 1
? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length })
: t('settings.remoteInstances.page.empty.noInstances')}
</p>
) : instances.map((instance) => {
const instanceStatus = statusesById[instance.id];
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const phase = instanceStatus?.phase;
const ready = phase === 'ready';
const state = instanceState(phase);
const failureDetail = state === 'error' ? instanceStatus?.detail : undefined;
return (
<div key={instance.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
<div key={instance.id} className="space-y-1.5 py-1.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${phaseDotClass(phase)}`} />
<p className="typography-ui-label text-foreground truncate">{title}</p>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(instanceStateLabelKey(state))}
{state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''}
{ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{ready ? (
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => openInstanceUrl(instanceStatus?.localUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
{t('settings.remoteInstances.page.actions.open')}
</Button>
) : null}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
<p className="typography-micro text-muted-foreground truncate">
{t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const op = ready ? disconnect(instance.id) : connect(instance.id);
void op.catch((err) => toast.error(ready ? t('settings.remoteInstances.sidebar.toast.disconnectFailed') : t('settings.remoteInstances.sidebar.toast.connectFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
{ready ? <Icon name="stop" className="h-3.5 w-3.5" /> : <Icon name="plug-2" className="h-3.5 w-3.5" />}
{ready ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => setSelectedId(instance.id)}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => {
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(instance.id).catch((err) => toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
}));
}}>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')}
</Button>
</div>
{failureDetail ? (
<p className="typography-micro text-[var(--status-error)] break-words">{failureDetail}</p>
) : null}
</div>
);
})}
@@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => {
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('settings.remoteInstances.sidebar.actions.addSshInstance')}</DialogTitle>
<DialogDescription>{t('settings.remoteInstances.page.section.instanceDescription')}</DialogDescription>
<DialogDescription>{t('settings.remoteInstances.page.addDialog.description')}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
<SettingsChipGroup
value={sshAddMode}
onChange={setSshAddMode}
aria-label={t('settings.remoteInstances.page.addDialog.sourceLabel')}
options={[
{ value: 'saved', label: t('settings.remoteInstances.page.addDialog.tab.saved') },
{ value: 'manual', label: t('settings.remoteInstances.page.addDialog.tab.manual') },
]}
/>
{sshAddMode === 'saved' ? (
<div className="space-y-2">
<Input
className="h-8"
value={sshHostSearch}
onChange={(event) => setSshHostSearch(event.target.value)}
placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')}
autoFocus
/>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.emptySaved')}</p>
) : filteredImportCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.addDialog.searchEmpty')}</p>
) : (
<div className="max-h-[45vh] overflow-auto">
{filteredImportCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-2.5 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.remoteInstances.page.addDialog.use')}
</Button>
</div>
))}
</div>
)}
</div>
</form>
) : (
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); void createSshInstanceFromDialog(); }}>
<Input className="h-8" value={sshNameDraft} onChange={(event) => setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} />
<Input className="h-8" value={sshCommandDraft} onChange={(event) => setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => setSshAddDialogOpen(false)} disabled={isSaving}>{t('settings.common.actions.cancel')}</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={isSaving || !sshCommandDraft.trim()}>{t('settings.common.actions.create')}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog> : null}
{showInstanceManagement ? <SettingsSection
title={t('settings.remoteInstances.page.import.sectionTitle')}
>
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div>
{importCandidates.map((candidate) => (
<div key={`${candidate.source}:${candidate.host}`} className="flex items-center justify-between gap-3 border-b border-[var(--surface-subtle)] py-3 last:border-b-0">
<div className="min-w-0">
<div className="typography-ui-label font-medium text-foreground truncate">
{candidate.host}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-meta text-muted-foreground truncate">{candidate.sshCommand}</div>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
{t('settings.common.actions.import')}
</Button>
</div>
))}
</div>
)}
</SettingsSection> : null}
<Dialog
open={Boolean(patternHost)}
onOpenChange={(open) => {
@@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => {
}
const isManagedMode = draft.remoteOpenchamber.mode === 'managed';
// Publishing the remote server to its network turns the UI password from an
// option into the only thing standing in front of it.
const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0';
const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim();
const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id;
return (
@@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {
<h1 className={`${SETTINGS_PAGE_TITLE_CLASS} truncate`}>{instanceTitle}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{t(phaseLabelKey(statusPhase))}</span>
<span className="text-foreground">{t(instanceStateLabelKey(currentState))}</span>
{currentState === 'connecting' ? <span>{t(phaseLabelKey(statusPhase))}</span> : null}
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
</div>
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => {
{t('settings.remoteInstances.sidebar.actions.remove')}
</Button>
</div>
{currentState === 'error' && status?.detail ? (
<div className="space-y-2 rounded-md border border-[var(--status-error)]/30 bg-[var(--status-error-background)] p-3">
<p className="typography-meta text-[var(--status-error)] break-words">{status.detail}</p>
{currentRemedyHintKey ? (
<p className="typography-micro text-muted-foreground">{t(currentRemedyHintKey)}</p>
) : null}
{currentRemedy && !currentRemedyHintKey ? (
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void applyErrorRemedy(currentRemedy)}
>
{currentRemedy === 'uiPassword'
? t('settings.remoteInstances.page.error.action.setUiPassword')
: currentRemedy === 'localPort'
? t('settings.remoteInstances.page.error.action.pickRandomPort')
: t('settings.remoteInstances.page.error.action.setRemotePort')}
</Button>
) : null}
</div>
) : null}
{status?.localUrl ? (
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
@@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
</SettingsSection>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
@@ -2099,8 +2308,40 @@ export const RemoteInstancesPage: React.FC = () => {
</Select>
</div>
</SettingsSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="mt-6 w-auto justify-start gap-1.5">
<span className={SETTINGS_SECTION_TITLE_CLASS}>{t('settings.remoteInstances.page.section.advanced')}</span>
<Icon name={advancedOpen ? 'arrow-up-s' : 'arrow-down-s'} className="h-4 w-4 text-muted-foreground" />
</CollapsibleTrigger>
<CollapsibleContent>
<p className="px-2 pb-2 typography-micro text-muted-foreground">{t('settings.remoteInstances.page.section.advancedHint')}</p>
<div className="flex flex-col gap-1.5 px-2 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
max={240}
step={1}
className="w-16 tabular-nums"
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
...current,
connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec,
}));
}}
/>
</div>
<SettingsSection
title={t('settings.remoteInstances.page.section.remoteServer')}
info={t('settings.remoteInstances.page.section.remoteServerDescription')}
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<div className="w-56 shrink-0" ref={remotePortRef}>
<HintLabel
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
@@ -2150,10 +2391,7 @@ export const RemoteInstancesPage: React.FC = () => {
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
installMethod:
value === 'npm' || value === 'download_release' || value === 'upload_bundle'
? value
: 'bun',
installMethod: value === 'npm' || value === 'bun' ? value : 'auto',
},
}))
}
@@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => {
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.remoteInstances.page.field.installMethodAuto')}</SelectItem>
<SelectItem value="bun">bun</SelectItem>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{isManagedMode ? (
<div className="py-1.5">
<div className="flex flex-col gap-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.remoteLanAccess')}
hint={t('settings.remoteInstances.page.field.remoteLanAccessHint')}
/>
</div>
<Switch
checked={remoteLanExposed}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
remoteOpenchamber: {
...current.remoteOpenchamber,
bindHost: checked ? '0.0.0.0' : '127.0.0.1',
},
}))
}
aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')}
/>
</div>
{remoteLanExposed ? (
<p className="mt-2 typography-micro text-[var(--status-warning)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.remoteLanAccessWarning')}
</p>
) : null}
</div>
) : null}
{isManagedMode ? (
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => {
}));
}}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[140px]">
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="w-fit min-w-[240px]">
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
<SelectItem value="localhost">localhost</SelectItem>
<SelectItem value="0.0.0.0">0.0.0.0</SelectItem>
<SelectItem value="127.0.0.1">{t('settings.remoteInstances.page.field.bindHostOption.loopback')}</SelectItem>
<SelectItem value="localhost">{t('settings.remoteInstances.page.field.bindHostOption.localhost')}</SelectItem>
<SelectItem value="0.0.0.0">{t('settings.remoteInstances.page.field.bindHostOption.lan')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</div>
</div>
<div className="space-y-1 pt-1">
<p className="typography-micro text-muted-foreground">{t('settings.remoteInstances.page.tunnelPreview.caption')}</p>
<p className="typography-micro font-mono text-foreground/80 break-all">
{`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'} → ${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`}
</p>
</div>
</SettingsSection>
<SettingsSection
@@ -2301,7 +2576,12 @@ export const RemoteInstancesPage: React.FC = () => {
contentClassName="space-y-3"
>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={t('settings.remoteInstances.page.field.sshPasswordOptional')}
hint={t('settings.remoteInstances.page.field.sshPasswordHint')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
type="password"
@@ -2324,10 +2604,21 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
<div className="w-56 shrink-0">
<HintLabel
label={remoteLanExposed
? t('settings.remoteInstances.page.field.uiPasswordRequired')
: t('settings.remoteInstances.page.field.uiPasswordOptional')}
hint={isManagedMode
? t('settings.remoteInstances.page.field.uiPasswordHintManaged')
: t('settings.remoteInstances.page.field.uiPasswordHintExternal')}
/>
</div>
<Input
className="h-7 md:max-w-sm"
className={cn('h-7 md:max-w-sm', uiPasswordMissing && 'border-[var(--status-error)]')}
type="password"
ref={uiPasswordRef}
aria-invalid={uiPasswordMissing}
value={draft.auth.openchamberPassword?.value || ''}
onChange={(event) =>
updateDraft((current) => ({
@@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => {
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/>
</div>
{uiPasswordMissing ? (
<p className="typography-micro text-[var(--status-error)] md:pl-[16rem]">
{t('settings.remoteInstances.page.field.uiPasswordMissingForLan')}
</p>
) : null}
</SettingsSection>
<SettingsSection
@@ -2621,6 +2917,9 @@ export const RemoteInstancesPage: React.FC = () => {
</Button>
</SettingsSection>
</CollapsibleContent>
</Collapsible>
<div className="mt-8 border-t border-[var(--interactive-border)] pt-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
@@ -8,6 +8,8 @@ import {
} from '@/components/ui/dropdown-menu';
import { Icon } from "@/components/icon/Icon";
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -17,8 +19,11 @@ const formatProjectLabel = (label: string): string => label.trim();
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
// Settings-only selection. Picking a project here used to call
// `setActiveProject`, which relocates the chat, the session list and the file
// tree; reading another project's configuration must not move the app.
const settingsDirectory = useSettingsDirectory();
const setSettingsProjectPath = useUIStore((state) => state.setSettingsProjectPath);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -30,8 +35,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
if (sortedProjects.length === 0) {
return null;
}
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
}, [activeProjectId, sortedProjects]);
return sortedProjects.find((p) => p.path === settingsDirectory) ?? sortedProjects[0];
}, [settingsDirectory, sortedProjects]);
if (isVSCode || sortedProjects.length === 0) {
return null;
@@ -67,7 +72,9 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
value={activeProject?.id ?? ''}
onValueChange={(value) => {
if (!value) return;
setActiveProject(value);
const project = sortedProjects.find((entry) => entry.id === value);
if (!project) return;
setSettingsProjectPath(project.path);
}}
>
{sortedProjects.map((project) => {
@@ -5,7 +5,8 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectSkillsForDirectory, useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -119,7 +120,6 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail,
createSkill,
updateSkill,
skills,
skillDraft,
setSkillDraft,
setSelectedSkill,
@@ -129,13 +129,16 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail: s.getSkillDetail,
createSkill: s.createSkill,
updateSkill: s.updateSkill,
skills: s.skills,
skillDraft: s.skillDraft,
setSkillDraft: s.setSkillDraft,
setSelectedSkill: s.setSelectedSkill,
})));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName, settingsDirectory) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
@@ -232,7 +235,7 @@ const SkillsInstalledPage: React.FC = () => {
} else if (selectedSkillName && selectedSkill) {
setIsLoading(true);
try {
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
const md = detail.sources.md;
const nextDescription = md.description || '';
@@ -253,7 +256,7 @@ const SkillsInstalledPage: React.FC = () => {
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
}, [selectedSkill, isNewSkill, selectedSkillName, settingsDirectory, skills, skillDraft, getSkillDetail]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -338,14 +341,14 @@ const SkillsInstalledPage: React.FC = () => {
let success: boolean;
if (isNewSkill) {
success = await createSkill(config);
success = await createSkill(config, settingsDirectory);
if (success) {
setSkillDraft(null);
setPendingFiles([]);
setSelectedSkill(skillName);
}
} else {
success = await updateSkill(skillName, config);
success = await updateSkill(skillName, config, settingsDirectory);
if (success) {
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
@@ -402,7 +405,7 @@ const SkillsInstalledPage: React.FC = () => {
try {
const { readSupportingFile } = useSkillsStore.getState();
const content = await readSupportingFile(selectedSkillName, filePath);
const content = await readSupportingFile(selectedSkillName, filePath, settingsDirectory);
setNewFileContent(content || '');
setOriginalFileContent(content || '');
} catch {
@@ -448,13 +451,13 @@ const SkillsInstalledPage: React.FC = () => {
}
const { writeSupportingFile } = useSkillsStore.getState();
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent, settingsDirectory);
if (success) {
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
setIsFileDialogOpen(false);
setEditingFilePath(null);
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -484,11 +487,11 @@ const SkillsInstalledPage: React.FC = () => {
setIsDeletingFile(true);
const { deleteSupportingFile } = useSkillsStore.getState();
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath, settingsDirectory);
if (success) {
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { selectSkillsForDirectory, useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -49,7 +50,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const {
selectedSkillName,
skills,
setSelectedSkill,
setSkillDraft,
deleteSkill,
@@ -57,7 +57,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
deleteSkill: s.deleteSkill,
@@ -65,7 +64,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail: s.getSkillDetail,
})));
// Skills are loaded by the Settings shell when this page is active.
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((state) => state.loadSkills);
React.useEffect(() => {
void loadSkills(settingsDirectory);
}, [loadSkills, settingsDirectory]);
const bgClass = 'bg-background';
@@ -101,7 +108,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
setIsDeletePending(true);
const success = await deleteSkill(deleteDialogSkill.name);
const success = await deleteSkill(deleteDialogSkill.name, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
setDeleteDialogSkill(null);
@@ -124,7 +131,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Get full skill detail to copy
const detail = await getSkillDetail(skill.name);
const detail = await getSkillDetail(skill.name, settingsDirectory);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
return;
@@ -173,7 +180,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
const success = await renameSkill(renameDialogSkill.name, sanitizedName, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
@@ -439,12 +446,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
const sourceLabel = skill.source === 'claude'
? t('settings.skills.sidebar.badge.claude')
: skill.source === 'agents'
? t('settings.skills.sidebar.badge.agents')
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
@@ -479,10 +480,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className={badgeClassName}>
{skill.scope}
</span>
<span className={badgeClassName}>{sourceLabel}</span>
</div>
</button>
@@ -391,7 +391,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
type="button"
data-settings-item="skills.catalog.add-catalog"
onClick={() => setAddCatalogOpen(true)}
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--interactive-border)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
>
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
<Icon name="add" className="h-4 w-4" />
@@ -346,9 +346,11 @@ const SessionFolderItemBase = <TSessionNode,>({
{subFolderItems}
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
)
<div className="pl-3">
{sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
)}
</div>
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
{t('sessions.sidebar.folderItem.emptyFolder')}
@@ -1,4 +1,7 @@
import React from 'react';
import { getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
@@ -39,7 +42,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarNav } from './sidebar/SidebarNav';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarActivitySections, type ActivityItem } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
@@ -100,6 +103,7 @@ import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { isCapacitorApp } from '@/lib/platform';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
@@ -439,6 +443,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const liveSessionIndex = getAllSyncSessionMap();
const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const runtimeKey = getRuntimeKey();
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
const activeSessionStructure = useGlobalSessionsStore(useShallow(
(state) => state.activeSessions.map(getSessionStructuralSignature).sort(),
@@ -446,9 +451,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const archivedSessionStructure = useGlobalSessionsStore(useShallow(
(state) => state.archivedSessions.map(getSessionStructuralSignature).sort(),
));
const globalSessionSnapshot = useGlobalSessionsStore.getState();
const globalActiveSessions = globalSessionSnapshot.activeSessions;
const archivedSessions = globalSessionSnapshot.archivedSessions;
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
const liveFallbackCacheRef = React.useRef<{ signature: string; sessions: Session[] }>({
signature: '',
sessions: [],
@@ -506,20 +510,19 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
);
const sessions = React.useMemo(() => {
const merged = [...globalActiveSessions];
const seenIds = new Set(merged.map((session) => session.id));
const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions);
liveFallbackSessions.forEach((session) => {
if (seenIds.has(session.id)) {
return;
}
merged.push(session);
});
return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, {
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
}));
return merged.filter((session) => (
// btw forks stay hidden until promoted to a full session
!isBtwSession(session)
&& (
(!isVSCode && isChatDirectoryPath(session.directory))
|| isKnownActiveSessionDirectory(session, knownSessionDirectories, {
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
})
)
));
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
const persistenceSessions = React.useMemo(
@@ -532,7 +535,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
syncSessionsSnapshotRef.current = liveSessions;
}, [liveSessions]);
const runtimeKey = getRuntimeKey();
const projectWorktreeDiscoveryKey = React.useMemo(
() => `${runtimeKey}|${projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
@@ -546,15 +548,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
React.useEffect(() => {
if (initialGlobalSessionsRefreshStartedRef.current) {
return;
}
initialGlobalSessionsRefreshStartedRef.current = true;
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
React.useEffect(() => {
let cancelled = false;
@@ -927,10 +920,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number, increment: number = 7) => {
setVisibleSessionCountByGroup((prev) => {
const next = new Map(prev);
next.set(groupId, currentVisibleCount + 7);
next.set(groupId, currentVisibleCount + increment);
return next;
});
}, []);
@@ -1137,10 +1130,16 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId);
const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId);
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const supportsSingleProjectMode = !isVSCode && !isCapacitorApp();
const isSingleProjectMode = projectDisplayMode === 'single' && supportsSingleProjectMode;
const shouldShowRecentSection = showRecentSection && !isSingleProjectMode;
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
const projectExpandedParents = selectExpandedParentKeysForContext(
@@ -1185,7 +1184,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
githubAuthStatus,
githubAuthChecked,
updateStore,
showRecentSection,
showRecentSection: shouldShowRecentSection,
showArchivedSessions,
projectSortOrder,
projectRepoStatus,
@@ -1365,13 +1364,17 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}, [projectSections, homeDirectory]);
const recentSessions = React.useMemo(() => {
if (!showRecentSection || isVSCode) {
if (!shouldShowRecentSection || isVSCode) {
return [];
}
return deriveRecentSessions(sessions, activeSessionIdSet)
return deriveRecentSessions(sessions.filter((session) => !isChatDirectoryForHome(session.directory, homeDirectory)), activeSessionIdSet)
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
}, [activeSessionIdSet, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]);
}, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, shouldShowRecentSection]);
const chatSessions = React.useMemo(() => sessions
.filter((session) => !session.parentID && !session.time?.archived && isChatDirectoryForHome(session.directory, homeDirectory))
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)), [homeDirectory, pinnedSessionIds, sessionOrderRanks, sessions]);
// Prefetch is wired below, after recentSessions is computed.
@@ -1379,13 +1382,13 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
// VS Code renders the full grouped project view (one group per open
// workspace, folders + pinned native); the flat "recent" activity list is
// web/desktop-only.
if (isVSCode || !showRecentSection) {
if (isVSCode) {
return [];
}
const toItem = (session: Session) => {
const existing = sessionSidebarMetaById.get(session.id);
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
const sessionDirectory = normalizePath(session.directory ?? null);
const node = existing?.node ?? { session, children: [], worktree: null };
const filteredNodes = hasSessionSearchQuery
? filterSessionNodesForSearch([node], normalizedSessionSearchQuery)
@@ -1408,17 +1411,21 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
};
};
const items = recentSessions
const recentItems = shouldShowRecentSection ? recentSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null) : [];
const chatItems = chatSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null);
return [
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items },
{ key: 'chats' as const, title: t('sessions.sidebar.activity.chatsTitle'), items: chatItems },
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: recentItems },
];
}, [filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]);
}, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, shouldShowRecentSection, t]);
const hasActivitySectionItems = React.useMemo(
() => activitySections.some((section) => section.items.length > 0),
() => activitySections.some((section) => section.key === 'chats' || section.items.length > 0),
[activitySections],
);
@@ -1441,6 +1448,19 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
: section
));
}, [flatSectionsForRender, sectionsForRender, showInlineArchived, useGroupedSections]);
const effectiveSingleProjectId = React.useMemo(() => {
if (!isSingleProjectMode) return null;
if (singleProjectId && projectSections.some((section) => section.project.id === singleProjectId)) {
return singleProjectId;
}
if (activeProjectId && projectSections.some((section) => section.project.id === activeProjectId)) {
return activeProjectId;
}
return projectSections[0]?.project.id ?? null;
}, [activeProjectId, isSingleProjectMode, projectSections, singleProjectId]);
const handleSingleProjectSelect = React.useCallback((projectId: string) => {
setSingleProjectId(projectId);
}, [setSingleProjectId]);
// Discover/refresh PR status for expanded projects' worktree branches so
// session rows can tint their branch marker and show PR state in tooltips.
@@ -1660,6 +1680,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
visibleSessionCount={visibleSessionCountByGroup.get(groupKey)}
sessionBatchSize={isSingleProjectMode && sessionGroupingMode === 'flat' && group.id !== 'managed-chats'
? 20
: undefined}
collapsedGroups={collapsedGroups}
hideDirectoryControls={hideDirectoryControls}
collapsedFolderIds={collapsedFolderIds}
@@ -1702,6 +1725,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
normalizedSessionSearchQuery,
groupSearchDataByGroup,
visibleSessionCountByGroup,
isSingleProjectMode,
sessionGroupingMode,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
@@ -1736,8 +1761,48 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
],
);
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
useUIStore.getState().closeMainSurfaces();
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
const renderChatsSection = React.useCallback((items: ActivityItem[]) => {
const chatsRoot = getChatsRootForHome(homeDirectory)
?? items.map((item) => getChatsRootFromDirectory(item.node.session.directory)).find(Boolean)
?? null;
if (!chatsRoot) return items.map((item) => renderSessionNode(item.node, 0, item.groupDirectory));
const folderDirectories = [
chatsRoot,
...items.map((item) => normalizePath(item.node.session.directory ?? null)).filter((directory): directory is string => Boolean(directory)),
];
const folderScopes = Array.from(new Set(folderDirectories)).map((directory) => ({
scopeKey: directory,
directory,
}));
const group: SessionGroup = {
id: 'managed-chats',
label: '',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: chatsRoot,
folderScopeKey: chatsRoot,
folderScopes,
draftTarget: 'chat',
emptyMessage: t('sessions.sidebar.activity.chatsEmpty'),
sessions: items.map((item) => item.node),
};
return renderGroupSessions(group, 'managed-chats', null, true);
}, [homeDirectory, renderGroupSessions, renderSessionNode, t]);
const topContent = React.useMemo(
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
() => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
@@ -1746,9 +1811,12 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
expansionState={recentExpandedParents}
variant="section"
isDesktopShellRuntime={isDesktopShellRuntime}
onNewChat={handleOpenNewSessionDraftFromHeader}
alwaysShowActions={alwaysShowSidebarActions}
renderChatsSection={renderChatsSection}
/>
) : null,
[activitySections, editingId, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection],
[activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderChatsSection, renderSessionNode],
);
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
@@ -1789,15 +1857,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
openMultiRunLauncher();
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
useUIStore.getState().closeMainSurfaces();
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
return (
// One shared tooltip provider for the whole sidebar: session tooltips open
// instantly, and moving between rows hands the tooltip over (grouping)
@@ -1846,6 +1905,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
showProjectDisplayControls={supportsSingleProjectMode}
showRecentControls={!isVSCode}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
onOpenScheduled={() => {
@@ -1878,7 +1938,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
projectPickerSections={projectSections}
activeProjectId={activeProjectId}
singleProjectMode={isSingleProjectMode}
singleProjectId={effectiveSingleProjectId}
setSingleProjectId={handleSingleProjectSelect}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
emptyState={emptyState}
@@ -5,6 +5,7 @@
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
- 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.
- **Project display is independent from grouping.** `'all'` keeps every project zone; `'single'` is web/desktop/PWA-only and renders one selected project under the always-present Chats section. Its project header is a non-collapsible picker ordered by the current project sort. Recent and collapse/expand-all controls are hidden without changing their persisted preferences. Opening a materialized project session updates the picker from the session's confirmed directory; changing only a draft target does not. In `'single'` + `'flat'`, active sessions reveal in batches of 20. `'single'` + `'by-worktree'` retains the ordinary per-group limits. Project display mode, session grouping, project sort, and the Recent preference are server-backed shared settings with the hydrated browser store as the migration/failure cache. The selected single project and sticky-header preference remain device-local.
- 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.
@@ -13,6 +14,11 @@
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
- Managed Chats never offer the worktree-move action in either the sidebar row menu or the active-session header menu because their directories are not project repositories.
- Managed Chats use the shared Chats root as their folder scope. Their activity section renders the normal folder tree, and sessions created from a Chats folder are assigned back to that root-scoped folder after their date/session directory materializes. Per-session folder scopes created by older builds remain visible for compatibility.
- An empty Chats section says that there are no chats yet; it never reuses the project/workspace empty message.
- The New session keyboard command inherits the active materialized session directory. Explicit sidebar entry points, including the top New session row and the Chats `+`, open a fresh managed Chat draft instead.
- The new-worktree keyboard command is a silent no-op while a managed Chat draft is open. It must not retarget that draft to the active project or show a Git/worktree error because Chats never participate in worktrees.
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
@@ -29,7 +35,7 @@
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
- A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory.
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header.
- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent.
- `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.
@@ -58,6 +58,7 @@ type Props = {
normalizedSessionSearchQuery: string;
groupSearchDataByGroup: WeakMap<SessionGroup, GroupSearchData>;
visibleSessionCount?: number;
sessionBatchSize?: number;
collapsedGroups: Set<string>;
hideDirectoryControls: boolean;
collapsedFolderIds: Set<string>;
@@ -76,7 +77,7 @@ type Props = {
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number, increment?: number) => void;
resetGroupSessionLimit: (groupKey: string) => void;
mobileVariant: boolean;
alwaysShowActions: boolean;
@@ -84,7 +85,7 @@ type Props = {
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string }) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
renamingFolderId: string | null;
@@ -190,6 +191,7 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
if (prev.sessionBatchSize !== next.sessionBatchSize) return false;
if (prev.collapsedGroups !== next.collapsedGroups
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
@@ -288,6 +290,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
normalizedSessionSearchQuery,
groupSearchDataByGroup,
visibleSessionCount,
sessionBatchSize,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
@@ -401,7 +404,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
setIsRequestingBootstrapAccess(false);
}
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
const maxVisible = hideDirectoryControls ? 10 : 5;
const maxVisible = sessionBatchSize ?? (hideDirectoryControls ? 10 : 5);
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
const shouldFilterGroupContents = hasSessionSearchQuery;
@@ -878,7 +881,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id });
openNewSessionDraft({
selectedProjectId: projectId,
directoryOverride: scopeDirectory ?? group.directory,
targetFolderId: folder.id,
target: group.draftTarget,
});
}}
hideActions={false}
archivedBucket={group.isArchivedBucket === true}
@@ -1048,7 +1056,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
)
: bootstrapFailureNotice
? bootstrapFailureNotice
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
: group.emptyMessage ?? t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{totalSessions > 0 && bootstrapFailureNotice ? (
@@ -1059,7 +1067,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
{remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length, sessionBatchSize ?? 7)}
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showMore')}
@@ -43,6 +43,7 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
import { FusionIcon } from '@/components/icons/FusionIcon';
@@ -957,7 +958,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')}
</Item>
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
{!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
@@ -1015,6 +1016,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
.forEach((worktree) => pushScope(worktree.path));
}
}
pushScope(getChatsRootFromDirectory(sessionDirectory));
pushScope(sessionDirectory);
const folderEntries = scopes.flatMap((scope) =>
getFoldersForScope(scope).map((folder) => ({ scope, folder })));
@@ -10,8 +10,9 @@ import {
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type ActivityItem = {
export type ActivityItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
@@ -22,7 +23,7 @@ type ActivityItem = {
};
type ActivitySection = {
key: 'active-now';
key: 'active-now' | 'chats';
title: string;
items: ActivityItem[];
};
@@ -46,6 +47,9 @@ type Props = {
initialVisibleCount?: number;
batchSize?: number;
isDesktopShellRuntime: boolean;
onNewChat?: () => void;
alwaysShowActions?: boolean;
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
};
type RenderExtras = SessionNodeRenderExtras;
@@ -129,7 +133,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
});
}, [editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0);
const visibleSections = sections.filter((section) => (
section.items.length > 0 || (section.key === 'chats' && props.onNewChat)
));
if (visibleSections.length === 0) {
return null;
}
@@ -146,7 +152,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
);
const visibleItems = section.items.slice(0, visibleLimit);
const remainingCount = section.items.length - visibleItems.length;
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
const renderItem = (item: ActivityItem) => renderSessionNode(
item.node,
@@ -178,29 +185,67 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
return (
<div key={section.key} className="relative space-y-1">
<div
className="absolute h-px w-px pointer-events-none"
data-sidebar-activity-sentinel={section.key}
aria-hidden="true"
/>
<div className={cn(
'relative group/chats',
'-ml-2.5 -mr-2',
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
<button
type="button"
onClick={() => toggleSection(section.key)}
className="group flex w-full items-center gap-1.5 py-1 pl-4 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className={cn(
'group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
section.key === 'chats' && props.onNewChat ? 'pr-10' : 'pr-3.5',
)}
aria-expanded={!isCollapsed}
>
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
<Icon name="history" className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
</span>
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
</button>
{section.key === 'chats' && props.onNewChat ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
props.onNewChat?.();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
props.alwaysShowActions
? 'opacity-100'
: 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto',
)}
aria-label={t('sessions.sidebar.header.actions.newSession')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{t('sessions.sidebar.header.actions.newSession')}</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
{!isCollapsed ? (
<div className={cn('space-y-0.5')}>
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
{section.key === 'chats' && props.renderChatsSection
? props.renderChatsSection(section.items)
: visibleItems.map(renderItem)}
{!usesCustomRenderer && remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
@@ -13,9 +13,11 @@ import { Icon } from "@/components/icon/Icon";
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useI18n } from '@/lib/i18n';
import { updateDesktopSettings } from '@/lib/persistence';
type Props = {
hideDirectoryControls: boolean;
showProjectDisplayControls: boolean;
showRecentControls: boolean;
handleOpenDirectoryDialog: () => void;
onOpenScheduled: () => void;
@@ -41,6 +43,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
const { t } = useI18n();
const {
hideDirectoryControls,
showProjectDisplayControls,
showRecentControls,
handleOpenDirectoryDialog,
onOpenScheduled,
@@ -70,6 +73,9 @@ export function SidebarHeader(props: Props): React.ReactNode {
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
const setProjectDisplayMode = useSessionDisplayStore((state) => state.setProjectDisplayMode);
const isSingleProjectMode = showProjectDisplayControls && projectDisplayMode === 'single';
if (hideDirectoryControls) {
return null;
@@ -205,7 +211,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
] as const).map(([order, labelKey]) => (
<DropdownMenuItem
key={order}
onClick={() => setProjectSortOrder(order)}
onClick={() => {
setProjectSortOrder(order);
void updateDesktopSettings({ sidebarProjectSortOrder: order });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
@@ -213,6 +222,28 @@ export function SidebarHeader(props: Props): React.ReactNode {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{showProjectDisplayControls ? (
<>
<DropdownMenuLabel>{t('sessions.sidebar.header.projectDisplay.label')}</DropdownMenuLabel>
{([
['all', 'sessions.sidebar.header.projectDisplay.all'],
['single', 'sessions.sidebar.header.projectDisplay.single'],
] as const).map(([mode, labelKey]) => (
<DropdownMenuItem
key={mode}
onClick={() => {
setProjectDisplayMode(mode);
void updateDesktopSettings({ sidebarProjectDisplayMode: mode });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
{projectDisplayMode === mode ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuLabel>{t('sessions.sidebar.header.grouping.label')}</DropdownMenuLabel>
{([
['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'],
@@ -220,7 +251,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
] as const).map(([mode, labelKey]) => (
<DropdownMenuItem
key={mode}
onClick={() => setSessionGroupingMode(mode)}
onClick={() => {
setSessionGroupingMode(mode);
void updateDesktopSettings({ sidebarSessionGroupingMode: mode });
}}
className="flex items-center justify-between"
>
<span>{t(labelKey)}</span>
@@ -228,9 +262,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{showRecentControls ? (
{showRecentControls && !isSingleProjectMode ? (
<DropdownMenuItem
onClick={toggleRecentSection}
onClick={() => {
toggleRecentSection();
void updateDesktopSettings({ sidebarShowRecentSection: !showRecentSection });
}}
className="flex items-center justify-between"
>
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
@@ -244,15 +281,19 @@ export function SidebarHeader(props: Props): React.ReactNode {
<span>{t('sessions.sidebar.header.displayMode.stickyHeaders')}</span>
{stickyZoneHeaders ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<Icon name="contract-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<Icon name="expand-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
</DropdownMenuItem>
{!isSingleProjectMode ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
<Icon name="contract-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
<Icon name="expand-up-down" className="h-4 w-4" />
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -37,6 +37,13 @@ type ProjectSection = {
const TOP_FADE_MAX_SIZE = 48;
const TOP_FADE_MIN_SIZE = 32;
const TOP_FADE_CLEAR_MAX_SIZE = 24;
type ActivitySectionKey = 'chats' | 'active-now';
const readActivitySectionKey = (element: Element): ActivitySectionKey | null => {
const key = element.getAttribute('data-sidebar-activity-sentinel');
if (key === 'chats' || key === 'active-now') return key;
return null;
};
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
formatProjectLabel(
@@ -52,7 +59,11 @@ type Props = {
hasSharedSessions?: boolean;
sectionsForRender: ProjectSection[];
projectSections: ProjectSection[];
projectPickerSections: ProjectSection[];
activeProjectId: string | null;
singleProjectMode: boolean;
singleProjectId: string | null;
setSingleProjectId: (id: string) => void;
showOnlyMainWorkspace: boolean;
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
@@ -98,7 +109,7 @@ type Props = {
function SidebarProjectsListComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders && !props.singleProjectMode;
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -106,6 +117,21 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
const groupSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const selectedSingleProjectSection = props.singleProjectMode
? props.sectionsForRender.find((section) => section.project.id === props.singleProjectId)
: null;
const renderedProjectSections = props.singleProjectMode
? (selectedSingleProjectSection ? [selectedSingleProjectSection] : [])
: props.sectionsForRender;
const projectPickerOptions = React.useMemo(() => props.projectPickerSections.map((section) => ({
id: section.project.id,
projectLabel: getProjectLabel(section.project, props.homeDirectory),
projectDescription: formatPathForDisplay(section.project.normalizedPath, props.homeDirectory),
projectIcon: section.project.icon,
projectColor: section.project.color,
projectIconImage: section.project.iconImage,
projectIconBackground: section.project.iconBackground,
})), [props.homeDirectory, props.projectPickerSections]);
// Memoize getOrderedGroups per project so downstream consumers see a stable
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
@@ -135,6 +161,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
const [leadingActivitySection, setLeadingActivitySection] = React.useState<ActivitySectionKey>('chats');
// Keep per-scroll measurements out of React state so the interaction guard
// can read the current fade boundary without rerendering the sidebar.
const topFadeSizeRef = React.useRef(0);
@@ -161,12 +188,44 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
event.preventDefault();
event.stopPropagation();
}, []);
const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0;
const hasProjectScroller = props.projectSections.length > 0 && renderedProjectSections.length > 0;
React.useLayoutEffect(() => {
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
syncTopFade(scrollContainerRef.current);
}
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
React.useEffect(() => {
const root = scrollContainerRef.current;
if (!enableStickyFade || !root || !props.hasSharedSessions) return;
const sentinels = Array.from(root.querySelectorAll<HTMLElement>('[data-sidebar-activity-sentinel]'));
if (sentinels.length === 0) return;
const stuckSections = new Set<ActivitySectionKey>();
const syncLeadingSection = (): void => {
let nextSection = sentinels[0] ? readActivitySectionKey(sentinels[0]) : null;
for (const sentinel of sentinels) {
const key = readActivitySectionKey(sentinel);
if (key && stuckSections.has(key)) nextSection = key;
}
if (nextSection) setLeadingActivitySection((current) => current === nextSection ? current : nextSection);
};
const observer = new IntersectionObserver((entries) => {
const rootTop = root.getBoundingClientRect().top;
for (const entry of entries) {
const key = readActivitySectionKey(entry.target);
if (!key) continue;
if (!entry.isIntersecting && entry.boundingClientRect.top < (entry.rootBounds?.top ?? rootTop)) {
stuckSections.add(key);
} else {
stuckSections.delete(key);
}
}
syncLeadingSection();
}, { root, threshold: 0 });
sentinels.forEach((sentinel) => observer.observe(sentinel));
syncLeadingSection();
return () => observer.disconnect();
}, [enableStickyFade, props.hasSharedSessions, props.topContent]);
let stuckProject: ProjectSection['project'] | null = null;
for (const section of props.projectSections) {
if (props.stuckProjectHeaders.has(section.project.id)) {
@@ -180,7 +239,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// ready in the same frame; the observer then corrects it. When shared sessions
// lead the list, the Recent fallback below owns the top instead of a project.
const leadingProject =
stuckProject ?? (props.hasSharedSessions ? null : props.sectionsForRender[0]?.project ?? null);
stuckProject ?? (props.hasSharedSessions ? null : renderedProjectSections[0]?.project ?? null);
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
if (props.sharedSessionsOnly) {
@@ -271,20 +330,20 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
props.reorderProjects(oldIndex, newIndex);
}}
>
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{props.sectionsForRender.map((section) => {
<SortableContext items={renderedProjectSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{renderedProjectSections.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = getProjectLabel(project, props.homeDirectory);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey);
const isCollapsed = props.singleProjectMode ? false : props.collapsedProjects.has(projectKey);
const isRepo = props.projectRepoStatus.get(projectKey);
return (
<SortableProjectItem
key={projectKey}
id={projectKey}
disabled={props.projectSortOrder !== 'manual'}
disabled={props.singleProjectMode || props.projectSortOrder !== 'manual'}
projectLabel={projectLabel}
projectDescription={projectDescription}
projectIcon={project.icon}
@@ -298,7 +357,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
mobileVariant={props.mobileVariant}
alwaysShowActions={props.alwaysShowActions}
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
onToggle={() => props.toggleProject(projectKey)}
onToggle={() => {
if (!props.singleProjectMode) props.toggleProject(projectKey);
}}
onNewSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
@@ -320,6 +381,8 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
showCreateButtons
openSidebarMenuKey={props.openSidebarMenuKey}
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
projectPickerOptions={props.singleProjectMode ? projectPickerOptions : undefined}
onProjectSelect={props.singleProjectMode ? props.setSingleProjectId : undefined}
>
{!isCollapsed ? (
<div className="space-y-0 pt-0.5 pb-0.5">
@@ -393,9 +456,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
/>
) : (
<>
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
<Icon name={leadingActivitySection === 'chats' ? 'chat-4' : 'history'} className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
{t('sessions.sidebar.activity.recentTitle')}
{t(leadingActivitySection === 'chats'
? 'sessions.sidebar.activity.chatsTitle'
: 'sessions.sidebar.activity.recentTitle')}
</span>
</>
)}
@@ -2,6 +2,7 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches } from '@/stores/useGitStore';
@@ -9,6 +10,8 @@ import type { SessionNode } from '../types';
import { isPathWithinProject } from '../utils';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
export type SwitcherItem = {
node: SessionNode;
@@ -51,6 +54,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
const branchesByDirectory = useGitAllBranches();
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// Worktree sessions live OUTSIDE their project's path, so prefix matching
// can't resolve their project — and their branch is known from worktree
@@ -114,6 +118,9 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const parents = activeSessions
.filter((session) => !session.time?.archived)
// btw forks stay hidden until promoted to a full session
.filter((session) => !isBtwSession(session))
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
if (!scopeProjectId) return true;
@@ -151,7 +158,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
return items;
};
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { mergeSidebarSessionSources } from './sidebarSessionSources';
const session = (id: string, title: string): Session => ({
id,
slug: id,
title,
directory: `/home/.config/openchamber/chats/2026-08-21/${id}`,
projectID: 'managed-chats',
version: '1',
time: { created: 1, updated: 1 },
});
describe('sidebar session source merge', () => {
test('shows one row when the same cached global chat also exists live', () => {
const live = session('session-a', 'Live title');
const cached = session('session-a', 'Cached title');
expect(mergeSidebarSessionSources([cached], [live])).toEqual([cached]);
});
test('prefers global authority over live fallback', () => {
const global = session('session-a', 'Global title');
expect(mergeSidebarSessionSources([global], [session('session-a', 'Live title')])).toEqual([global]);
});
});
@@ -0,0 +1,19 @@
import type { Session } from '@opencode-ai/sdk/v2';
export function mergeSidebarSessionSources(
globalSessions: readonly Session[],
liveSessions: readonly Session[],
): Session[] {
const merged = [...globalSessions];
const seenIds = new Set(merged.map((session) => session.id));
const appendMissing = (sessions: readonly Session[]) => {
sessions.forEach((session) => {
if (seenIds.has(session.id)) return;
seenIds.add(session.id);
merged.push(session);
});
};
appendMissing(liveSessions);
return merged;
}
@@ -30,6 +30,10 @@ type ProjectIdentityProps = {
projectIconBackground?: string;
};
type ProjectPickerOption = ProjectIdentityProps & {
projectDescription: string;
};
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
isCollapsed?: boolean;
alwaysShowActions?: boolean;
@@ -121,6 +125,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
setOpenSidebarMenuKey: (key: string | null) => void;
/** Aggregated activity/attention indicator shown while the project is collapsed. */
statusIndicator?: React.ReactNode;
projectPickerOptions?: ProjectPickerOption[];
onProjectSelect?: (projectId: string) => void;
}
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
@@ -150,6 +156,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
openSidebarMenuKey,
setOpenSidebarMenuKey,
statusIndicator = null,
projectPickerOptions,
onProjectSelect,
}) => {
const { t } = useI18n();
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
@@ -227,6 +235,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
}
onToggle();
}, [onToggle]);
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
return (
<div
@@ -273,39 +282,92 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
{...attributes}
>
<Tooltip>
<TooltipTrigger asChild>
{isProjectPicker ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
title={projectDescription}
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
isCollapsed={isCollapsed}
alwaysShowActions={alwaysShowActions}
/>
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
/>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[220px] max-w-[calc(100vw-2rem)] max-h-[min(var(--available-height),70vh)] overflow-y-auto overscroll-contain"
>
{projectPickerOptions?.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => onProjectSelect?.(option.id)}
className="flex items-center justify-between gap-3"
title={option.projectDescription}
>
<span className="flex min-w-0 items-center gap-1.5">
<ProjectHeaderIdentity
id={option.id}
projectLabel={option.projectLabel}
projectIcon={option.projectIcon}
projectColor={option.projectColor}
projectIconImage={option.projectIconImage}
projectIconBackground={option.projectIconBackground}
/>
</span>
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
isCollapsed={isCollapsed}
alwaysShowActions={alwaysShowActions}
/>
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
)}
<div className={cn(
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
@@ -29,6 +29,8 @@ export type SessionGroup = {
* instead of reading the single folderScopeKey.
*/
folderScopes?: SessionGroupFolderScope[];
draftTarget?: 'chat' | 'project';
emptyMessage?: string;
sessions: SessionNode[];
};
@@ -18,6 +18,7 @@ import {
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isBtwSession } from '@/lib/sessionBtwMetadata';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
EMPTY_SESSION_ORDER_RANKS,
@@ -308,7 +309,9 @@ export const CommandPalette: React.FC = () => {
// Sessions
// ---------------------------------------------------------------------------
const orderedActiveSessions = React.useMemo(() => {
return orderSessionsByLifecycleScopes(activeSessions, pinnedSessionIds, sessionOrderRanks);
// btw forks stay hidden until promoted to a full session
const visibleSessions = activeSessions.filter((session) => !isBtwSession(session));
return orderSessionsByLifecycleScopes(visibleSessions, pinnedSessionIds, sessionOrderRanks);
}, [activeSessions, pinnedSessionIds, sessionOrderRanks]);
const allBranches = useGitAllBranches();
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
describe('provider logo fallbacks', () => {
test('uses a local terminal icon when Command Code has no resolved logo', () => {
expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box');
test('uses a local terminal icon for Command Code provider ID variants', () => {
for (const providerId of ['command-code', 'commandcode', 'command_code', 'command code']) {
expect(getProviderLogoFallbackIcon(providerId)).toBe('terminal-box');
}
});
test('does not replace providers with their own logo assets', () => {
@@ -1,5 +1,9 @@
import type { IconName } from '@/components/icon/icons';
const COMMAND_CODE_PROVIDER_IDS = new Set(['command-code', 'commandcode', 'command_code', 'command code']);
export function getProviderLogoFallbackIcon(providerId: string | null | undefined): IconName | null {
return providerId?.trim().toLowerCase() === 'command-code' ? 'terminal-box' : null;
return providerId && COMMAND_CODE_PROVIDER_IDS.has(providerId.trim().toLowerCase())
? 'terminal-box'
: null;
}
+3
View File
@@ -163,6 +163,7 @@ type SelectContentExtra = {
sideOffset?: number;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
collisionAvoidance?: React.ComponentProps<typeof BaseSelect.Positioner>["collisionAvoidance"];
};
function SelectContent({
@@ -174,6 +175,7 @@ function SelectContent({
sideOffset,
side,
align,
collisionAvoidance,
...props
}: React.ComponentProps<typeof BaseSelect.Popup> & SelectContentExtra) {
const portalContext = React.useContext(SelectPortalContext);
@@ -187,6 +189,7 @@ function SelectContent({
sideOffset={sideOffset}
side={side}
align={align}
collisionAvoidance={collisionAvoidance}
className="absolute z-[120] pointer-events-auto"
>
<BaseSelect.Popup
@@ -35,6 +35,11 @@ export const UsageProviderCards: React.FC<{
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.planLabel ? (
<span className="shrink-0 typography-micro capitalize text-muted-foreground">
{group.planLabel}
</span>
) : null}
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">{group.status}</span>
) : null}
@@ -54,12 +59,12 @@ export const UsageProviderCards: React.FC<{
);
return (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="shrink-0 truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">
<span className="min-w-0 truncate typography-micro text-muted-foreground/70">
{resetLabel}
</span>
) : null}
@@ -15,6 +15,7 @@ export type UsageLimitRow = {
export type UsageProviderGroup = {
providerId: QuotaProviderId;
providerName: string;
planLabel?: string | null;
rows: UsageLimitRow[];
/** Provider-level message: a fetch error, or "nothing reported". */
status: string | null;
@@ -76,6 +77,7 @@ export const useUsageProviderGroups = (): UsageProviderGroup[] => {
return {
providerId: providerMeta.id,
providerName: providerMeta.name,
planLabel: result.planLabel,
rows,
status,
};
+351 -31
View File
@@ -3,9 +3,12 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import type { GitStatus } from '@/lib/api/types';
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
import {
DropdownMenu,
DropdownMenuContent,
@@ -79,7 +82,7 @@ type DiffData = {
fileDiff?: FileDiffMetadata;
contextMode?: DiffContextMode;
};
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
type TurnSnapshotDiff = {
file?: string;
@@ -91,6 +94,17 @@ type TurnSnapshotDiff = {
deletions?: number;
};
/** Reservation slot for a branch range diff while its fetch is in flight. */
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
original: '',
modified: '',
isBinary: false,
contextMode: 'patch',
};
/** Bounded retries for branch metadata in the context diff panel (see effect). */
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
@@ -230,11 +244,13 @@ const formatDiffTotals = (
};
interface ChangeScopeSelectorProps {
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
workingCount: number;
stagedCount: number;
turnCount: number;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
branchCount: number | null;
showBranchOption: boolean;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
}
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
@@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
workingCount,
stagedCount,
turnCount,
branchCount,
showBranchOption,
onScopeChange,
}) => {
const { t } = useI18n();
const [open, setOpen] = React.useState(false);
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
const currentLabel = scope === 'staged'
? t('diffView.scope.staged')
: scope === 'turn'
? t('diffView.scope.lastTurn')
: t('diffView.scope.changed');
: scope === 'branch'
? t('diffView.scope.branch')
: t('diffView.scope.changed');
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => {
if (value === 'working' || value === 'staged' || value === 'turn') {
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
onScopeChange?.(value);
setOpen(false);
}
@@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
<span className="typography-meta text-muted-foreground">{turnCount}</span>
</span>
</DropdownMenuRadioItem>
{showBranchOption ? (
<DropdownMenuRadioItem value="branch">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>{t('diffView.scope.branch')}</span>
<span className="typography-meta text-muted-foreground">{branchCount ?? '…'}</span>
</span>
</DropdownMenuRadioItem>
) : null}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps {
staged?: boolean;
loadFullFiles?: boolean;
initialDiffData?: DiffData | null;
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
readOnlyActions?: boolean;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
staged = false,
loadFullFiles = false,
initialDiffData = null,
readOnlyActions = false,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
/>
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
<div className="pointer-events-auto">
<FileDiffActions
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
onAction={handleFileAction}
/>
{!readOnlyActions ? (
<FileDiffActions
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
onAction={handleFileAction}
/>
) : null}
</div>
</div>
</>
@@ -945,7 +978,7 @@ interface DiffViewProps {
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
targetFilePath?: string | null;
/** Render diff content flush with the container edges (no outer padding). */
flushContent?: boolean;
@@ -974,6 +1007,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const setDiff = useGitStore((state) => state.setDiff);
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
@@ -1083,7 +1117,213 @@ export const DiffView: React.FC<DiffViewProps> = ({
return map;
}, [lastTurnDiffs]);
const workingFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isWorkingStatusFile).length;
}, [status]);
const stagedFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isStagedStatusFile).length;
}, [status]);
const turnFileCount = lastTurnDiffs.length;
// ----- Branch scope (all changes on this branch vs its base) -----
const currentBranch = status?.current ?? null;
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
// The Branch scope needs defaultBranches metadata that nothing else loads
// when only the context diff panel is open (GitView and the composer fetch
// it, and their absence must not hide the option), so load it here. A
// failed fetch leaves `branches` null and the loading flag settles back to
// false; the bounded retry below re-issues it a few times per directory and
// reports exhaustion so a dead repository neither loops forever nor spins
// the Branch scope on base resolution.
const startBranchMetadataFetch = React.useCallback(() => {
if (effectiveDirectory) {
void fetchBranches(effectiveDirectory, git);
}
}, [effectiveDirectory, fetchBranches, git]);
const branchMetadataExhausted = useBoundedDirectoryRetry(
effectiveDirectory ?? null,
isGitRepo !== false,
isLoadingBranches,
Boolean(branches),
startBranchMetadataFetch,
BRANCH_METADATA_MAX_ATTEMPTS
);
const repositoryDefaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin
?? null;
}, [branches, status?.tracking]);
// Offered only while the default branch is known and the current branch is
// not it (an unknown default must not flash the option on a guess), and
// only outside VS Code (the extension has no context diff panel).
const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch);
// Coercion acts only on CONFIRMED unavailability: the runtime has no branch
// scope at all, a settled status has no branch (detached HEAD), the default
// branch is known and we are on it, or metadata retries were exhausted.
// While status/metadata are still loading a persisted branch scope must
// survive instead of being rewritten to working on the first render.
// `status !== null` is the settled test: before the first status request
// even starts, status is null with loading still false, and that must not
// read as "settled without a branch".
const isBranchStatusResolved = status !== null;
const branchScopeDefinitelyUnavailable = isVSCodeRuntime()
|| branchMetadataExhausted
|| isBranchScopeDefinitelyUnavailable(
currentBranch,
repositoryDefaultBranch,
isBranchStatusResolved,
branches !== null
);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
// Subscribe to the overrides map directly: `getOverride` reads `get()`
// imperatively, so a memo over it never recomputes when the store changes
// and a freshly picked base would be invisible until an unrelated rerender.
// The key includes the current branch: a base picked for one feature branch
// is not an answer for another branch of the same repository.
const baseOverride = useGitBaseBranchStore(
React.useCallback(
(state) => (effectiveDirectory && currentBranch
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
: null),
[currentBranch, effectiveDirectory]
)
);
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
const [basePickerSearch, setBasePickerSearch] = React.useState('');
// A context tab persists its scope across branch checkouts and runtime
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
// known default branch, VS Code runtime), fall back to Working instead of
// rendering the base-resolution spinner forever. Persist the coercion so
// the tab and the selector agree. Note it keys off confirmed
// unavailability, not off `showBranchOption`: while metadata loads the
// option is hidden but a persisted branch scope must not be rewritten.
React.useEffect(() => {
const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable);
if (coercedScope !== activeDiffScope) {
setActiveDiffScope(coercedScope);
// The only coercion is 'branch' -> 'working', so the persisted
// value always fits the callback domain.
if (coercedScope === 'working') {
onDiffScopeChange?.('working');
}
}
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
React.useEffect(() => {
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
setDetectedBranchBase(null);
setIsBranchBaseResolved(false);
return;
}
let cancelled = false;
setIsBranchBaseResolved(false);
getBranchBase(effectiveDirectory, currentBranch)
.then((result) => {
if (!cancelled) setDetectedBranchBase(result.base);
})
.catch(() => {
if (!cancelled) setDetectedBranchBase(null);
})
.finally(() => {
if (!cancelled) setIsBranchBaseResolved(true);
});
return () => {
cancelled = true;
};
}, [currentBranch, effectiveDirectory, showBranchOption]);
// Explicit user choice outranks the detected source; both are real answers
// from git or the user — never a main/master guess.
const branchBase = baseOverride ?? detectedBranchBase;
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
// Shared by the scope/base effect and the error-state Retry button; the
// fetch id discards completions from a superseded run (base or head
// changed, or an earlier retry is still in flight).
const branchFilesFetchIdRef = React.useRef(0);
const reloadBranchFiles = React.useCallback(() => {
if (!effectiveDirectory || !currentBranch || !branchBase) return;
const fetchId = branchFilesFetchIdRef.current + 1;
branchFilesFetchIdRef.current = fetchId;
setBranchFiles(null);
setBranchFilesError(null);
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
.then((files) => {
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
})
.catch((error) => {
if (branchFilesFetchIdRef.current === fetchId) {
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
}
});
}, [branchBase, currentBranch, effectiveDirectory, t]);
React.useEffect(() => {
if (activeDiffScope === 'branch') {
reloadBranchFiles();
}
}, [activeDiffScope, reloadBranchFiles]);
// Range diffs are fetched per expanded file: unlike working/staged diffs
// there is no per-file cache channel, so patch data lives in a range-keyed
// local cache. Stale completions from a previous range cannot write into
// the new range's cache (see useRangeKeyedCache).
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
: null;
const branchDiffPathsKey = React.useMemo(
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
[activeDiffScope, expandedFiles]
);
const fetchBranchDiffEntry = React.useCallback(
(filePath: string) => {
if (!effectiveDirectory || !branchBase || !currentBranch) {
return Promise.reject(new Error('branch range is unavailable'));
}
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
},
[branchBase, currentBranch, effectiveDirectory]
);
const branchDiffData = useRangeKeyedCache<DiffData>(
branchDiffRangeKey,
branchDiffPathsKey,
branchDiffRangeKey ? fetchBranchDiffEntry : null,
EMPTY_BRANCH_DIFF_PLACEHOLDER
);
const branchFileCount = branchFiles?.length ?? null;
const changedFiles: FileEntry[] = React.useMemo(() => {
if (activeDiffScope === 'branch') {
return (branchFiles ?? [])
.map((file) => ({
path: file.path,
index: '',
working_dir: file.status,
insertions: 0,
deletions: 0,
isNew: file.status === 'A',
}))
.sort((a, b) => a.path.localeCompare(b.path));
}
if (activeDiffScope === 'turn') {
return lastTurnDiffs
.map((diff) => ({
@@ -1115,19 +1355,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
isNew: isNewStatusFile(file),
}))
.sort((a, b) => a.path.localeCompare(b.path));
}, [activeDiffScope, lastTurnDiffs, status]);
const workingFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isWorkingStatusFile).length;
}, [status]);
const stagedFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isStagedStatusFile).length;
}, [status]);
const turnFileCount = lastTurnDiffs.length;
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
const changedFilePathsKey = React.useMemo(
() => changedFiles.map((file) => file.path).join('\0'),
@@ -1670,7 +1898,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
}}
staged={getFileStaged(file.path)}
loadFullFiles={loadFullFiles}
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
readOnlyActions={activeDiffScope === 'branch'}
initialDiffData={
activeDiffScope === 'turn'
? lastTurnDiffData.get(file.path) ?? null
: activeDiffScope === 'branch'
? branchDiffData.get(file.path) ?? null
: null
}
/>
))}
</div>
@@ -1707,10 +1942,93 @@ export const DiffView: React.FC<DiffViewProps> = ({
);
}
if (activeDiffScope === 'branch') {
if (!isBranchBaseResolved) {
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('diffView.branch.resolvingBase')}
</div>
);
}
if (!branchBase) {
const searchTerm = basePickerSearch.trim().toLowerCase();
const candidateBranches = (branches?.all ?? [])
.map((name: string) => name.replace(/^remotes\//, ''))
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
.sort();
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Icon name="git-branch" className="size-6 text-muted-foreground" />
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
<input
type="text"
value={basePickerSearch}
onChange={(event) => setBasePickerSearch(event.target.value)}
placeholder={t('gitView.branch.searchPlaceholder')}
aria-label={t('gitView.branch.searchPlaceholder')}
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
/>
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
{candidateBranches.length === 0 ? (
<div className="px-2 py-3 typography-meta text-muted-foreground">
{t('gitView.branch.empty')}
</div>
) : (
<div className="flex flex-col gap-0.5">
{candidateBranches.map((branch: string) => (
<button
key={branch}
type="button"
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
>
<Icon name="git-branch" className="size-3.5 text-primary" />
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
</button>
))}
</div>
)}
</ScrollableOverlay>
</div>
);
}
if (branchFilesError) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.loadError')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{branchFilesError}</div>
<Button
variant="outline"
size="sm"
onClick={() => reloadBranchFiles()}
>
{t('diffView.actions.retry')}
</Button>
</div>
);
}
if (branchFiles === null) {
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('diffView.branch.loadingFiles')}
</div>
);
}
}
if (changedFiles.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
: t('diffView.state.cleanWorkingTree')}
</div>
);
}
@@ -1722,12 +2040,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
<ChangeScopeSelector
scope={activeDiffScope}
workingCount={workingFileCount}
stagedCount={stagedFileCount}
turnCount={turnFileCount}
branchCount={branchFileCount}
showBranchOption={showBranchOption}
onScopeChange={(scope) => {
setActiveDiffScope(scope);
onDiffScopeChange?.(scope);

Some files were not shown because too many files have changed in this diff Show More