merge: resolve v1.23.0 upstream conflicts, preserve custom git provider config

Resolved conflicts in 8 files by taking upstream refactored code:
- desktop.ts: re-export DesktopSettings from registry
- openchamberConfig.ts: simplified project setup client
- persistence.ts: registry-derived settings, add git provider hydration
- search.ts: upstream search entries + git provider entries
- useConfigStore.ts: loadDesktopSettings() path
- settings-helpers.js: add gitProviderId/gitModelId/gitProviders sanitization
- DOCUMENTATION.md: upstream walkthrough docs
- vite.config.ts: upstream SW glob patterns

Custom fork additions preserved:
- gitProviderId, gitModelId, gitProviders fields in settings registry
- Git provider domain store hydration in persistence.ts
- Git provider search entries in search.ts
- Git provider sanitization in settings-helpers.js
This commit is contained in:
2026-09-10 10:11:50 +00:00
559 changed files with 36139 additions and 9087 deletions
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
@@ -913,6 +914,7 @@ function App({ apis }: AppProps) {
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -957,6 +959,7 @@ function App({ apis }: AppProps) {
<MainLayout />
<Toaster />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
@@ -6,6 +6,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
@@ -329,6 +330,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<Toaster />
</div>
</TooltipProvider>
+19
View File
@@ -10,6 +10,7 @@ import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -83,8 +84,14 @@ const MOBILE_SETTINGS_PAGES = [
'sessions',
'git',
'magic-prompts',
'snippets',
'behavior',
'agents',
'commands',
'mcp',
'plugins',
'skills.installed',
'skills.catalog',
'providers',
'usage',
'voice',
@@ -295,6 +302,13 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
onRightEdgeSwipe: () => setWorkspaceOpen(true),
});
// Settings owns a drill-down of its own (nav → page list → item), so the
// hardware back button asks it to step up before the shell closes it.
const settingsBackRef = React.useRef<(() => boolean) | null>(null);
const registerSettingsBackHandler = React.useCallback((handler: (() => boolean) | null) => {
settingsBackRef.current = handler;
}, []);
// Top-most layer first: a plan or fullscreen surface can sit ABOVE a drawer
// (opened from the drawer footer / workspace tabs), so they close before the
// drawers underneath.
@@ -303,6 +317,9 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
setOpenPlan(null);
return true;
}
if (activeSurface === 'settings' && settingsBackRef.current?.()) {
return true;
}
if (activeSurface) {
closeSurface();
return true;
@@ -589,6 +606,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
forceMobile
isWindowed
initialMobileStage={settingsInitialMobileStage}
registerBackHandler={registerSettingsBackHandler}
// About exists for server updates — meaningful in a browser
// (hosted mobile), not in the Capacitor shell (store updates).
visiblePageSlugs={MOBILE_SETTINGS_PAGES.filter(
@@ -1288,6 +1306,7 @@ export function MobileApp({ apis }: MobileAppProps) {
setConnectionEpoch((value) => value + 1);
}} />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null}
</div>
@@ -0,0 +1,194 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { GitLogEntry, GitStatus } from '@/lib/api/types';
test('mobile comparisons drill into files, retry, resume, change source, and yield to external working diffs', async () => {
const dom = new Window({ url: 'http://localhost' });
dom.happyDOM.setWindowSize({ width: 390, height: 844 });
const originals = new Map<string, PropertyDescriptor | undefined>();
const globals = {
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location, localStorage: dom.localStorage,
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement, Node: dom.Node,
customElements: dom.customElements, CSSStyleSheet: dom.CSSStyleSheet,
Event: dom.Event, CustomEvent: dom.CustomEvent, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent,
MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver,
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const commits: GitLogEntry[] = ['a', 'b'].map((letter) => ({
hash: letter.repeat(40), date: '2026-09-09T09:22:00Z', message: `Commit ${letter}`,
refs: '', body: '', author_name: 'Test Author', author_email: 'test@example.com',
filesChanged: 1, insertions: 0, deletions: 0, parents: [],
}));
const requests: URL[] = [];
const originalFetch = globalThis.fetch;
let failBranchDiff = true;
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input), 'http://localhost');
if (url.pathname === '/api/fs/home' || url.pathname === '/api/session-folders') return new Promise<Response>(() => {});
requests.push(url);
switch (url.pathname) {
case '/api/git/remotes': return Response.json([]);
case '/api/git/remote-url': return Response.json({ url: null });
case '/api/git/branch-base': return Response.json({ base: null });
case '/api/git/range-files': return Response.json({ files: [{ path: url.searchParams.get('base') === 'refs/heads/parent' ? 'parent.png' : 'branch.png', status: 'M' }] });
case '/api/git/range-diff':
return failBranchDiff
? Response.json({ error: 'Branch diff failed' }, { status: 500 })
: Response.json({ diff: 'Binary files a/branch.png and b/branch.png differ' });
case '/api/git/log': return Response.json({ all: commits, latest: commits[0], total: commits.length });
case '/api/git/commit-files': return Response.json({ files: [{ path: `commit-${url.searchParams.get('hash')?.[0]}.png`, previousPath: 'old.png', changeType: 'R', insertions: 0, deletions: 0, isBinary: true }] });
case '/api/git/commit-diff': return Response.json({ diff: 'Binary files a/old.png and b/commit.png differ' });
case '/api/git/file-diff': return Response.json({ path: 'working.png', original: '', modified: '', isBinary: true });
default: throw new Error(`Unexpected request ${url.pathname}`);
}
}, originalFetch);
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('@/lib/i18n');
const { RuntimeAPIContext } = await import('@/contexts/runtimeAPIContext');
const { createWebAPIs } = await import('../../../web/src/api/index');
const { useGitStore } = await import('@/stores/useGitStore');
const { MobileChangesPane } = await import('./MobileChangesSurface');
const apis = createWebAPIs();
const status: GitStatus = { current: 'feature', tracking: null, ahead: 0, behind: 0, files: [], isClean: true, diffStats: {} };
const seed = (directory: string, nextStatus = status) => {
useGitStore.getState().setActiveDirectory(directory);
const previous = useGitStore.getState().getDirectoryState(directory);
if (!previous) throw new Error('Missing repository state');
const now = Date.now();
const directories = new Map(useGitStore.getState().directories);
directories.set(directory, {
...previous, status: nextStatus, isGitRepo: true,
branches: { all: ['feature', 'main', 'parent', 'remotes/origin/main'], current: 'feature', branches: {}, defaultBranches: { origin: 'main' } },
log: { all: commits, latest: commits[0], total: 2 }, identity: { userName: 'Test Author', userEmail: 'test@example.com', sshCommand: null },
lastStatusFetch: now, lastBranchesFetch: now, lastLogFetch: now, lastIdentityFetch: now, lastRepoCheckAt: now,
});
useGitStore.setState({ directories });
};
seed('/repo');
let directory = '/repo';
let visible = true;
let initialDiff: { path: string; staged: boolean } | null = null;
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
const render = () => act(async () => {
root.render(<I18nProvider><RuntimeAPIContext.Provider value={apis}>
<MobileChangesPane rootDirectory={directory}
repository={{ rootIsGitRepo: true, gitDirectory: directory, nestedRepos: null, nestedRepoSelection: null }}
visible={visible} initialDiff={initialDiff} />
</RuntimeAPIContext.Provider></I18nProvider>);
});
const click = async (selector: string) => {
const element = document.querySelector<HTMLElement>(selector);
if (!element) throw new Error(`Missing ${selector}`);
await act(async () => { element.click(); });
};
const chooseMode = async (label: string) => {
await click('[aria-label="Select change mode"]');
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((element) => element.textContent === label);
if (!option) throw new Error(`Missing mode ${label}`);
await act(async () => { option.click(); });
};
const openFile = async (path: string) => {
const button = container.querySelector(`[title="${path}"]`)?.closest('button');
if (!button) throw new Error(`Missing file ${path}`);
await act(async () => { button.click(); });
};
const comparisonRequests = () => requests.filter((url) => /\/(range-files|range-diff|commit-files|commit-diff)$/.test(url.pathname));
const checkoutControl = () => [...container.querySelectorAll('button')].find((button) => button.textContent?.trim() === 'feature');
try {
await render();
const modeTrigger = container.querySelector('[aria-label="Select change mode"]');
const syncButton = container.querySelector('[aria-label="Sync Changes"]');
if (!modeTrigger || !syncButton) throw new Error('Missing Changes controls');
expect(modeTrigger?.textContent).toBe('Changes');
expect(container.querySelector('h2')).toBeNull();
expect(checkoutControl()).toBeDefined();
expect(syncButton).not.toBeNull();
expect(modeTrigger?.closest('header')?.contains(syncButton)).toBe(false);
expect(modeTrigger && syncButton && (modeTrigger.compareDocumentPosition(syncButton) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
await chooseMode('Branch');
expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull();
expect(checkoutControl()).toBeUndefined();
expect(container.textContent).toContain('Select a base branch');
await click('[aria-label="Base branch"]');
await click('[data-value="refs/heads/main"]');
expect(container.querySelector('[title="branch.png"]')).not.toBeNull();
await openFile('branch.png');
expect(container.textContent).toContain('Branch diff failed');
expect(requests.filter((url) => url.pathname === '/api/git/file-diff')).toHaveLength(0);
failBranchDiff = false;
const retry = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Retry');
if (!retry) throw new Error('Missing diff retry');
await act(async () => { retry.click(); });
expect(container.textContent).toContain('Content of this file cannot be viewed.');
expect(container.textContent).toContain('Branch · main');
const beforeHide = comparisonRequests().length;
visible = false;
await render();
await act(async () => { seed('/repo'); });
expect(comparisonRequests()).toHaveLength(beforeHide);
visible = true;
await render();
expect(container.querySelector('h2')?.textContent).toBe('branch.png');
await click('[aria-label="Back"]');
await click('[aria-label="Base branch"]');
await click('[data-value="refs/heads/parent"]');
expect(container.querySelector('[title="branch.png"]')).toBeNull();
expect(container.querySelector('[title="parent.png"]')).not.toBeNull();
await chooseMode('Commit');
expect(container.querySelector('[aria-label="Sync Changes"]')).toBeNull();
expect(checkoutControl()).toBeUndefined();
expect(container.querySelector('[title="commit-a.png"]')).not.toBeNull();
await click('[aria-label="Select commit"]');
await click(`[data-value="${'b'.repeat(40)}"]`);
expect(container.querySelector('[title="commit-a.png"]')).toBeNull();
await openFile('commit-b.png');
expect(container.textContent).toContain('Commit · bbbbbbbb');
const commitRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/commit-diff');
expect(commitRequest?.searchParams.get('hash')).toBe('b'.repeat(40));
expect(commitRequest?.searchParams.get('previousPath')).toBe('old.png');
expect(requests.filter((url) => url.pathname === '/api/git/range-diff').every((url) => url.searchParams.get('includeWorkingTree') === 'true')).toBe(true);
await act(async () => { seed('/repo', { ...status, isClean: false, files: [{ path: 'working.png', index: 'M', working_dir: ' ' }] }); });
initialDiff = { path: 'working.png', staged: true };
await render();
expect(container.querySelector('h2')?.textContent).toBe('working.png');
const workingRequest = [...requests].reverse().find((url) => url.pathname === '/api/git/file-diff');
expect(workingRequest?.searchParams.get('staged')).toBe('true');
await click('[aria-label="Back"]');
expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes');
expect(container.querySelector('[aria-label="Sync Changes"]')).not.toBeNull();
expect(checkoutControl()).toBeDefined();
await chooseMode('Branch');
initialDiff = { path: 'working.png', staged: true };
await render();
expect(container.querySelector('h2')?.textContent).toBe('working.png');
await act(async () => { seed('/repo-two'); });
directory = '/repo-two';
await render();
expect(container.querySelector('[aria-label="Select change mode"]')?.textContent).toBe('Changes');
expect(container.querySelector('h2')).toBeNull();
expect(requests.some((url) => url.pathname === '/api/git/file-diff' && url.searchParams.get('directory') === '/repo-two')).toBe(false);
} finally {
await act(async () => root.unmount());
globalThis.fetch = originalFetch;
await dom.happyDOM.abort();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
+290 -58
View File
@@ -3,9 +3,15 @@ import { Icon } from '@/components/icon/Icon';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel';
import { BranchSelector } from '@/components/views/git/BranchSelector';
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
import { branchRefLabel } from '@/components/views/git/baseBranch';
import { isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache } from '@/components/views/branchDiffScope';
import { CommitSection } from '@/components/views/git/CommitSection';
import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog';
import { SyncActions } from '@/components/views/git/SyncActions';
@@ -13,6 +19,13 @@ import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
import { useCommitComparison } from '@/hooks/useCommitComparison';
import { useGitComparison, type GitComparisonFile, type GitComparisonSource } from '@/hooks/useGitComparison';
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { fileDiffFromPatch, isBinaryPatch } from '@/lib/diff/patchFileDiff';
import type { FileDiffMetadata } from '@pierre/diffs';
import type { GitStatus } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi';
@@ -32,6 +45,29 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type ChangesMode = 'working' | 'branch' | 'commit';
type ChangesRoute =
| { type: 'list' }
| { type: 'diff'; path: string; staged: boolean }
| { type: 'comparison'; path: string; sourceKey: string };
interface ChangesNavigation {
ownerKey: string;
mode: ChangesMode;
route: ChangesRoute;
}
interface MobileDiffData {
original: string;
modified: string;
isBinary?: boolean;
fileDiff?: FileDiffMetadata;
}
type ComparisonDiff =
| { status: 'loading' }
| { status: 'ready'; diff: MobileDiffData }
| { status: 'error'; message: string };
const LOADING_COMPARISON_DIFF: ComparisonDiff = { status: 'loading' };
const LIST_ROUTE: ChangesRoute = { type: 'list' };
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
@@ -51,21 +87,32 @@ type MobileChangesSurfaceProps = {
/** When provided, the list header gets a close X that calls this. */
onClose?: () => void;
/**
* When set (and non-null), the surface opens directly into the per-file diff view for this
* relative path. Updating it (incl. setting it to a different path while open) routes the
* surface to that diff. Setting it back to null leaves the user on the current internal route.
* A new request object opens its working-tree diff, including repeated requests
* for the same path. Reopening the drawer keeps the same object and navigation.
*/
initialDiffPath?: string | null;
initialDiffStaged?: boolean;
initialDiff?: { path: string; staged: boolean } | null;
/** The workspace drawer keeps visited panes mounted while hidden. */
visible?: boolean;
};
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onClose, initialDiffPath, initialDiffStaged = false }) => {
export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = (props) => {
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
const repository = useNestedGitDirectory(rootDirectory || null, { enabled: props.visible ?? true });
return <MobileChangesPane {...props} rootDirectory={rootDirectory} repository={repository} />;
};
interface MobileChangesPaneProps extends MobileChangesSurfaceProps {
rootDirectory: string;
repository: ReturnType<typeof useNestedGitDirectory>;
}
/** Repository-scoped navigation and actions, separate from session directory resolution. */
export const MobileChangesPane: React.FC<MobileChangesPaneProps> = ({ rootDirectory, repository, onClose, initialDiff, visible = true }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const rootDirectory = normalizePath(useEffectiveDirectory() ?? null);
// When the root is not itself a repository, changes come from the resolved
// nested repository instead.
const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null);
const { rootIsGitRepo, gitDirectory, nestedRepos } = repository;
const currentDirectory = gitDirectory ?? rootDirectory;
const status = useGitStatus(currentDirectory || null);
const branches = useGitBranches(currentDirectory || null);
@@ -81,21 +128,41 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const getDiff = useGitStore((state) => state.getDiff);
const setDiff = useGitStore((state) => state.setDiff);
const [route, setRoute] = React.useState<{ type: 'list' } | { type: 'diff'; path: string; staged: boolean }>(
() => (initialDiffPath ? { type: 'diff', path: initialDiffPath, staged: initialDiffStaged } : { type: 'list' }),
);
const runtimeKey = useGitStore((state) => state.runtimeKey);
const ownerKey = JSON.stringify([runtimeKey, currentDirectory]);
const ownerKeyRef = React.useRef(ownerKey);
ownerKeyRef.current = ownerKey;
const [navigation, setNavigation] = React.useState<ChangesNavigation>(() => ({
ownerKey,
mode: 'working',
route: initialDiff?.path ? { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } : LIST_ROUTE,
}));
const mode = navigation.ownerKey === ownerKey ? navigation.mode : 'working';
const route = navigation.ownerKey === ownerKey ? navigation.route : LIST_ROUTE;
const [modeMenuOpen, setModeMenuOpen] = React.useState(false);
const changeMode = React.useCallback((nextMode: ChangesMode) => {
setNavigation({ ownerKey, mode: nextMode, route: LIST_ROUTE });
setModeMenuOpen(false);
}, [ownerKey]);
const setRoute = React.useCallback((nextRoute: ChangesRoute) => {
setNavigation((current) => ({ ownerKey, mode: current.ownerKey === ownerKey ? current.mode : 'working', route: nextRoute }));
}, [ownerKey]);
React.useEffect(() => {
setNavigation((current) => current.ownerKey === ownerKey ? current : { ownerKey, mode: 'working', route: LIST_ROUTE });
setModeMenuOpen(false);
}, [ownerKey]);
React.useEffect(() => { if (!visible) setModeMenuOpen(false); }, [visible]);
// Allow the host (MobileApp) to push us into a specific diff when the surface
// is reopened or when an external trigger (e.g. PendingChangesBar tap) requests
// a different file mid-session.
React.useEffect(() => {
if (!initialDiffPath) return;
setRoute((current) => (
current.type === 'diff' && current.path === initialDiffPath && current.staged === initialDiffStaged
? current
: { type: 'diff', path: initialDiffPath, staged: initialDiffStaged }
));
}, [initialDiffPath, initialDiffStaged]);
if (!initialDiff?.path) return;
// A new external target is a working-tree diff, regardless of the last
// comparison mode. Changing directories alone must not replay this target.
setNavigation({ ownerKey: ownerKeyRef.current, mode: 'working', route: { type: 'diff', path: initialDiff.path, staged: initialDiff.staged } });
}, [initialDiff]);
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
const [commitMessage, setCommitMessage] = React.useState('');
@@ -110,6 +177,54 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const currentBranch = status?.current ?? null;
const trackingRemote = status?.tracking?.trim().split('/')[0];
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) ?? branches?.defaultBranches?.origin ?? null;
const showBranchOption = isBranchScopeAvailable(currentBranch, defaultBranch);
const branchUnavailable = isGitRepo === false || isBranchScopeDefinitelyUnavailable(currentBranch, defaultBranch, status !== null, branches !== null);
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
const branchComparison = useBranchComparisonBase(currentDirectory || null, currentBranch, visible && mode === 'branch' && showBranchOption);
const commitComparison = useCommitComparison(currentDirectory || null, currentBranch, visible && mode === 'commit' && isGitRepo === true);
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null;
const comparisonSource = React.useMemo<GitComparisonSource | null>(() => {
if (mode === 'branch' && currentBranch && branchComparison.base) return { kind: 'branch', baseRef: branchComparison.base, headRef: currentBranch };
if (mode === 'commit' && selectedCommitHash) return { kind: 'commit', hash: selectedCommitHash };
return null;
}, [branchComparison.base, currentBranch, mode, selectedCommitHash]);
const comparisonRevision = mode === 'branch' ? branchComparison.revision : '';
const comparison = useGitComparison(currentDirectory || null, comparisonSource, visible && isGitRepo === true, comparisonRevision);
const { fetchDiff: loadComparisonDiff } = comparison;
const comparisonFiles = React.useMemo(() => comparison.files ? [...comparison.files].sort((a, b) => a.path.localeCompare(b.path)) : null, [comparison.files]);
const activeComparisonPath = route.type === 'comparison' && route.sourceKey === comparison.key ? route.path : null;
const [comparisonRetry, setComparisonRetry] = React.useState(0);
const fetchComparisonDiff = React.useCallback(async (path: string): Promise<ComparisonDiff> => {
try {
const { diff: patch } = await loadComparisonDiff(path);
const diff: MobileDiffData = { original: '', modified: '', isBinary: isBinaryPatch(patch) };
if (!diff.isBinary) diff.fileDiff = fileDiffFromPatch(path, patch);
return { status: 'ready', diff };
} catch (error) {
return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') };
}
}, [loadComparisonDiff, t]);
const comparisonDiffs = useRangeKeyedCache<ComparisonDiff>(
comparison.files ? comparison.key : null,
visible && activeComparisonPath ? activeComparisonPath : '',
visible ? fetchComparisonDiff : null,
LOADING_COMPARISON_DIFF,
JSON.stringify([comparisonRevision, comparisonRetry]),
);
const activeComparisonDiff = activeComparisonPath ? comparisonDiffs.get(activeComparisonPath) ?? LOADING_COMPARISON_DIFF : null;
React.useEffect(() => {
if (mode === 'branch' && branchUnavailable) changeMode('working');
}, [branchUnavailable, changeMode, mode]);
React.useEffect(() => {
setNavigation((current) => current.ownerKey === ownerKey && current.route.type === 'comparison' && current.route.sourceKey !== comparison.key
? { ...current, route: LIST_ROUTE }
: current);
}, [comparison.key, ownerKey]);
const changeEntries = React.useMemo(() => {
const files = status?.files ?? [];
const unique = new Map<string, (typeof files)[number]>();
@@ -199,7 +314,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => {
if (!currentDirectory) return;
try {
await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD');
await git.createBranch(currentDirectory, branch, currentBranch ?? 'HEAD');
await git.checkoutBranch(currentDirectory, branch);
if (remote) {
await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] });
@@ -209,7 +324,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed'));
throw error;
}
}, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]);
}, [currentBranch, currentDirectory, git, refreshStatusAndBranches, t]);
const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory) {
@@ -231,17 +346,17 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
}, [currentDirectory, git]);
React.useEffect(() => {
if (!currentDirectory) return;
if (!currentDirectory || !visible) return;
setActiveDirectory(currentDirectory);
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git, setActiveDirectory]);
}, [currentDirectory, ensureAll, git, setActiveDirectory, visible]);
React.useEffect(() => {
void refreshRemotes();
}, [refreshRemotes]);
if (visible) void refreshRemotes();
}, [refreshRemotes, visible]);
React.useEffect(() => {
if (!currentDirectory || changeEntries.length === 0) return;
if (!visible || mode !== 'working' || !currentDirectory || changeEntries.length === 0) return;
const orderedPaths = Array.from(new Set([
...stagedChangeEntries.map((entry) => entry.path),
...visibleChangePaths,
@@ -252,9 +367,10 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: 40 });
}, 120);
return () => window.clearTimeout(timeoutId);
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
}, [changeEntries, currentDirectory, git, mode, prefetchDiffs, stagedChangeEntries, visibleChangePaths, visible]);
React.useEffect(() => {
if (!visible) return;
if (route.type !== 'diff') {
setDiffLoadError(null);
return;
@@ -285,7 +401,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
return () => {
cancelled = true;
};
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff]);
}, [currentDirectory, diffRetryNonce, getDiff, git, route, setDiff, visible]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return;
@@ -349,7 +465,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const handleViewChangeDiff = React.useCallback((path: string, staged = false) => {
setRoute({ type: 'diff', path, staged });
}, []);
}, [setRoute]);
const handleRevertFile = React.useCallback(async (filePath: string) => {
if (!currentDirectory) return;
@@ -580,9 +696,62 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
);
}
const modeLabel = mode === 'branch' ? t('diffView.scope.branch') : mode === 'commit' ? t('commitComparison.mode') : t('mobile.nav.changes');
const sourceLabel = mode === 'branch' && branchComparison.base
? branchRefLabel(branchComparison.base)
: mode === 'commit' ? selectedCommitHash?.slice(0, 8) : null;
if (activeComparisonPath && activeComparisonDiff) {
return (
<MobileDiffDetail
path={activeComparisonPath}
subtitle={[modeLabel, sourceLabel].filter(Boolean).join(' · ')}
diff={activeComparisonDiff.status === 'ready' ? activeComparisonDiff.diff : null}
fileExists={!comparison.files || comparison.files.some((file) => file.path === activeComparisonPath)}
error={comparison.error ?? (activeComparisonDiff.status === 'error' ? activeComparisonDiff.message : null)}
onBack={() => setRoute(LIST_ROUTE)}
onRetry={() => {
if (comparison.error) void comparison.refresh();
setComparisonRetry((value) => value + 1);
}}
/>
);
}
const renderComparison = () => {
if (mode === 'branch' && !branchComparison.base) {
return <MobileChangesState
loading={!branchComparison.resolved}
message={branchComparison.resolved ? t('gitView.pr.toast.baseBranchRequired') : t('diffView.branch.resolvingBase')}
/>;
}
if (mode === 'commit' && !selectedCommitHash) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
{commitComparison.loading && <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />}
<p className="typography-ui-label text-muted-foreground">{commitComparison.loading ? t('diffView.state.loadingChanges') : commitComparison.error ?? t('commitComparison.noCommits')}</p>
{commitComparison.error && <Button variant="outline" size="lg" onClick={() => void commitComparison.refresh()}>{t('diffView.actions.retry')}</Button>}
</div>;
}
if (comparison.error) {
return <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="typography-ui-label font-semibold">{t('diffView.state.failedToLoadDiff')}</p>
<p className="typography-meta text-muted-foreground">{comparison.error}</p>
<Button variant="outline" size="lg" onClick={() => void comparison.refresh()}>{t('diffView.actions.retry')}</Button>
</div>;
}
if (!comparisonFiles) return <MobileChangesState loading message={t('diffView.state.loadingChanges')} />;
if (comparisonFiles.length === 0) {
return <MobileChangesState icon={mode === 'branch'} message={mode === 'commit'
? t('commitComparison.emptyDiff')
: t('diffView.branch.empty', { base: sourceLabel ?? '' })} />;
}
return <MobileComparisonFileList files={comparisonFiles} onSelect={(path) => {
if (comparison.key) setRoute({ type: 'comparison', path, sourceKey: comparison.key });
}} />;
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3 text-foreground">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{onClose ? (
<button
type="button"
@@ -594,35 +763,68 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
<Icon name="close" className="size-5" />
</button>
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
<BranchSelector
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branches?.branches}
currentBranchAhead={status?.ahead}
onCheckout={(branch) => void handleCheckoutBranch(branch)}
onCreate={handleCreateBranch}
<DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={dropdownTriggerVariants({ size: 'default' })} data-mobile-comparison-trigger aria-label={t('diffView.scope.selectorAria')}>
<span>{modeLabel}</span>
<Icon name="arrow-down-s" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup value={mode} onValueChange={(value) => {
if (value === 'working' || value === 'branch' || value === 'commit') changeMode(value);
}}>
<DropdownMenuRadioItem value="working" className="min-h-8 items-center">{t('mobile.nav.changes')}</DropdownMenuRadioItem>
{showBranchOption && <DropdownMenuRadioItem value="branch" className="min-h-8 items-center">{t('diffView.scope.branch')}</DropdownMenuRadioItem>}
<DropdownMenuRadioItem value="commit" className="min-h-8 items-center">{t('commitComparison.mode')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{visible && mode === 'branch' && (
<BranchComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
branches={branches?.all ?? []} currentBranch={currentBranch} base={branchComparison.base}
onSelect={(base) => { if (currentBranch) setBaseOverride(currentDirectory, currentBranch, base); }} />
)}
{visible && mode === 'commit' && (
<CommitComparisonSelector mobile key={JSON.stringify([ownerKey, currentBranch])}
commits={commitComparison.commits} selectedHash={selectedCommitHash}
loading={commitComparison.loading} error={commitComparison.error}
onSelect={commitComparison.select} onRefresh={() => void commitComparison.refresh()} />
)}
</header>
{mode === 'working' && (
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<BranchSelector
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branches?.branches}
currentBranchAhead={status?.ahead}
onCheckout={(branch) => void handleCheckoutBranch(branch)}
onCreate={handleCreateBranch}
remotes={effectiveRemotes}
disabled={isLoadingStatus}
directory={currentDirectory}
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
</div>
<SyncActions
syncAction={syncAction}
remotes={effectiveRemotes}
disabled={isLoadingStatus}
directory={currentDirectory}
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
onFetch={(remote) => void handleSyncAction('fetch', remote)}
onSync={(remote) => void handleSyncAction('sync', remote)}
disabled={commitAction !== null || isLoadingStatus}
aheadCount={status?.ahead ?? 0}
behindCount={status?.behind ?? 0}
trackingRemoteName={status?.tracking?.split('/')[0]}
hasUncommittedChanges={changeEntries.length > 0}
/>
</div>
<SyncActions
syncAction={syncAction}
remotes={effectiveRemotes}
onFetch={(remote) => void handleSyncAction('fetch', remote)}
onSync={(remote) => void handleSyncAction('sync', remote)}
disabled={commitAction !== null || isLoadingStatus}
aheadCount={status?.ahead ?? 0}
behindCount={status?.behind ?? 0}
trackingRemoteName={status?.tracking?.split('/')[0]}
hasUncommittedChanges={changeEntries.length > 0}
/>
</header>
{changeEntries.length > 0 ? (
)}
{mode !== 'working' ? (
<div className="min-h-0 flex-1">{renderComparison()}</div>
) : changeEntries.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col">
{/* File list scrolls inside ChangesPanel; the commit footer stays pinned. */}
<div className="min-h-0 flex-1 overflow-hidden px-4 pt-4">
@@ -735,12 +937,13 @@ const MobileChangesState: React.FC<{
const MobileDiffDetail: React.FC<{
path: string;
diff: { original: string; modified: string; isBinary?: boolean } | null;
subtitle?: string;
diff: MobileDiffData | null;
fileExists: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
}> = ({ path, diff, fileExists, error, onBack, onRetry }) => {
}> = ({ path, subtitle, diff, fileExists, error, onBack, onRetry }) => {
const { t } = useI18n();
const language = React.useMemo(() => getLanguageFromExtension(path) || 'text', [path]);
@@ -757,6 +960,7 @@ const MobileDiffDetail: React.FC<{
</button>
<div className="min-w-0 flex-1 px-2">
<h2 className="truncate typography-ui-header text-foreground">{path}</h2>
{subtitle && <p className="truncate typography-meta text-muted-foreground">{subtitle}</p>}
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -774,7 +978,7 @@ const MobileDiffDetail: React.FC<{
<MobileChangesState loading message={t('diffView.state.loadingDiff')} />
) : diff.isBinary ? (
<MobileChangesState icon message={t('diffView.binary.unavailable')} />
) : isImageFile(path) ? (
) : isImageFile(path) && !diff.fileDiff ? (
<MobileChangesState icon message={t('mobile.changes.diffDetail.imageUnavailable')} />
) : (
<ScrollShadow
@@ -785,6 +989,7 @@ const MobileDiffDetail: React.FC<{
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
fileDiff={diff.fileDiff}
language={language}
fileName={path}
renderSideBySide={false}
@@ -797,3 +1002,30 @@ const MobileDiffDetail: React.FC<{
</div>
);
};
const MobileComparisonFileList: React.FC<{ files: GitComparisonFile[]; onSelect: (path: string) => void }> = ({ files, onSelect }) => {
const { t } = useI18n();
return (
<ScrollShadow className="h-full overflow-y-auto overflow-x-hidden px-3 py-2">
<ul aria-label={t('gitView.changes.changedFilesAria')} className="flex flex-col gap-1">
{files.map((file) => {
const statusLabel = file.status === 'A' ? t('diffView.change.new')
: file.status === 'D' ? t('diffView.change.deleted')
: file.status === 'R' ? t('diffView.change.renamed')
: file.status === 'C' ? t('diffView.change.copied') : t('diffView.change.modified');
const statusColor = file.status === 'A' ? 'var(--status-success)' : file.status === 'D' ? 'var(--status-error)'
: file.status === 'R' || file.status === 'C' ? 'var(--status-info)' : 'var(--status-warning)';
return <li key={file.path}>
<Button variant="ghost" size="lg" className="w-full justify-start gap-2.5 text-left normal-case" onClick={() => onSelect(file.path)}>
<span className="w-4 shrink-0 text-center typography-meta font-semibold uppercase" style={{ color: statusColor }} aria-label={statusLabel}>{file.status}</span>
<FileTypeIcon filePath={file.path} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label" title={file.path}>{file.path}</span>
{(file.insertions > 0 || file.deletions > 0) && <span className="shrink-0 typography-meta text-muted-foreground">+{file.insertions} -{file.deletions}</span>}
<Icon name="arrow-right-s" className="size-4 shrink-0 text-muted-foreground" />
</Button>
</li>;
})}
</ul>
</ScrollShadow>
);
};
@@ -175,7 +175,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
'flex flex-col bg-background text-foreground',
isDialog
? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]'
: 'oc-keyboard-inset-surface fixed inset-0 z-50',
: 'oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50',
)}
style={isDialog ? {
// Scale/fade instead of the push slide: the card is not a navigation
@@ -249,7 +249,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
return createPortal(
<div
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
style={{
background: 'rgb(0 0 0 / 0.45)',
opacity: entered ? 1 : 0,
+12 -3
View File
@@ -332,6 +332,8 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
),
);
const quotaResults = useQuotaStore((state) => state.results);
const quotaRefreshErrors = useQuotaStore((state) => state.refreshErrors);
const quotaRefreshAttempted = React.useRef(false);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
@@ -350,13 +352,20 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
}, [dropdownProviderIds]);
React.useEffect(() => {
if (!open || isQuotaLoading) return;
if (!open) {
quotaRefreshAttempted.current = false;
return;
}
if (quotaRefreshAttempted.current || isQuotaLoading) return;
const missingEnabledProvider = dropdownProviderIds.some((providerId) => (
!quotaResults.some((result) => result.providerId === providerId)
!quotaResults.some((result) => result.providerId === providerId) || quotaRefreshErrors[providerId]
));
if (!missingEnabledProvider) return;
// Trigger at most one attempt per opening. A failed first load remains
// unknown, not an empty result that can suppress retries.
quotaRefreshAttempted.current = true;
void fetchAllQuotas();
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]);
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults, quotaRefreshErrors]);
const latestMessageModel = React.useMemo(() => {
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
+245 -99
View File
@@ -36,6 +36,7 @@ import { DirectoryExplorerDialog } from '@/components/session/DirectoryExplorerD
import { Icon } from '@/components/icon/Icon';
import { NewWorktreeDialog } from '@/components/session/NewWorktreeDialog';
import { Button } from '@/components/ui/button';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Input } from '@/components/ui/input';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { toast } from '@/components/ui';
@@ -43,9 +44,11 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getProjectLabel, normalizePath } from './mobilePaths';
import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories';
import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection';
import { sortProjectsByOrder } from '@/components/session/sidebar/list/projectSort';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { updateDesktopSettings } from '@/lib/persistence';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import {
@@ -57,6 +60,7 @@ import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSess
import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore';
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionDisplayStore, type ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { orderWorktrees, useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
import {
@@ -73,6 +77,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
type MobileSessionsSheetProps = {
open: boolean;
@@ -94,6 +99,16 @@ type MobileSessionsSheetProps = {
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
// Same orders, same labels as the desktop sidebar's sort menu — the setting
// itself is shared, so the two surfaces must offer the same choices.
const PROJECT_SORT_OPTIONS = [
['manual', 'sessions.sidebar.header.projectSort.manual'],
['a-z', 'sessions.sidebar.header.projectSort.aToZ'],
['z-a', 'sessions.sidebar.header.projectSort.zToA'],
['date-added', 'sessions.sidebar.header.projectSort.dateAdded'],
['recent', 'sessions.sidebar.header.projectSort.recent'],
] as const;
// Pseudo-project key for the collapsible "recent" group's persisted expansion.
type ProjectMeta = {
@@ -106,6 +121,9 @@ type ProjectMeta = {
iconBackground?: string | null;
isGitRepo: boolean;
worktrees: WorktreeMetadata[];
/** Read by the 'date-added' / 'recent' project orders. */
addedAt?: number;
lastOpenedAt?: number;
};
type WorktreeBucket = {
@@ -268,13 +286,40 @@ const NewWorktreeIconButton: React.FC<{
);
};
/** Starts a session draft already pointed at this project — the mobile twin of
the desktop sidebar's per-project "+". */
const NewSessionIconButton: React.FC<{
label: string;
onClick: () => void;
className?: string;
}> = ({ label, onClick, className }) => (
<button
type="button"
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
className,
)}
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
style={{ touchAction: 'manipulation' }}
>
<Icon name="add" className="size-4" />
</button>
);
// Width of the swipe-revealed action area (rename + archive + delete buttons).
const ROW_ACTIONS_WIDTH = 144;
const ROW_SWIPE_SNAP_MS = 180;
/** Generic swipe-left-to-reveal wrapper for drawer rows (projects, worktrees).
/** Generic swipe-right-to-reveal wrapper for drawer rows (projects, worktrees).
Same gesture mechanics as SessionRow's swipe actions: horizontal intent
detection, imperative transform during the drag, snap on release. */
detection, imperative transform during the drag, snap on release. The
actions sit on the LEFT so the opposite direction stays free for the
drawer's own close swipe. */
const MobileSwipeActionsRow: React.FC<{
actionsWidth: number;
actions: React.ReactNode;
@@ -298,7 +343,7 @@ const MobileSwipeActionsRow: React.FC<{
React.useEffect(() => {
revealedRef.current = revealed;
applyOffset(revealed ? -actionsWidth : 0, true);
applyOffset(revealed ? actionsWidth : 0, true);
}, [actionsWidth, applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => {
@@ -317,16 +362,16 @@ const MobileSwipeActionsRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true;
}
const base = revealedRef.current ? -actionsWidth : 0;
applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false);
const base = revealedRef.current ? actionsWidth : 0;
applyOffset(Math.max(0, Math.min(actionsWidth, base + dx)), false);
};
const handleTouchEnd = () => {
startRef.current = null;
if (!draggingRef.current) return;
draggingRef.current = false;
const shouldReveal = offsetRef.current < -actionsWidth / 2;
applyOffset(shouldReveal ? -actionsWidth : 0, true);
const shouldReveal = offsetRef.current > actionsWidth / 2;
applyOffset(shouldReveal ? actionsWidth : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal);
};
@@ -339,7 +384,7 @@ const MobileSwipeActionsRow: React.FC<{
onTouchCancel={handleTouchEnd}
style={{ touchAction: 'pan-y' }}
>
<div className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
<div className="absolute inset-y-0 left-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
{actions}
</div>
<div ref={contentRef} className="relative flex w-full items-center bg-background">
@@ -445,7 +490,7 @@ const SessionRow: React.FC<{
expanded?: boolean;
onToggleChildren?: () => void;
onSelect: () => void;
/** Swipe-left actions. When omitted, the row is a plain non-swipeable row. */
/** Swipe-right actions. When omitted, the row is a plain non-swipeable row. */
revealed?: boolean;
onRevealedChange?: (revealed: boolean) => void;
confirmingDelete?: boolean;
@@ -508,7 +553,7 @@ const SessionRow: React.FC<{
React.useEffect(() => {
revealedRef.current = revealed;
applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true);
applyOffset(revealed ? ROW_ACTIONS_WIDTH : 0, true);
}, [applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => {
@@ -527,8 +572,8 @@ const SessionRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true;
}
const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0;
const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx));
const base = revealedRef.current ? ROW_ACTIONS_WIDTH : 0;
const next = Math.max(0, Math.min(ROW_ACTIONS_WIDTH, base + dx));
applyOffset(next, false);
};
@@ -536,8 +581,8 @@ const SessionRow: React.FC<{
startRef.current = null;
if (!draggingRef.current) return;
draggingRef.current = false;
const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2;
applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true);
const shouldReveal = offsetRef.current > ROW_ACTIONS_WIDTH / 2;
applyOffset(shouldReveal ? ROW_ACTIONS_WIDTH : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal);
};
@@ -554,32 +599,14 @@ const SessionRow: React.FC<{
>
{swipeEnabled ? (
<div
className="absolute inset-y-0 right-0 flex items-stretch"
className="absolute inset-y-0 left-0 flex items-stretch"
style={{ width: ROW_ACTIONS_WIDTH }}
aria-hidden={!revealed}
>
{/* Icon-only actions on the row's own background — they read as the
row extending to reveal extra controls, not a separate panel. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
row extending to reveal extra controls, not a separate panel.
Ordered outward from the content, so a partial drag exposes
delete first, exactly as the right-side version did. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
@@ -597,6 +624,26 @@ const SessionRow: React.FC<{
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</div>
) : null}
<div
@@ -884,6 +931,9 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder);
const removeProject = useProjectsStore((state) => state.removeProject);
const projectExpandedMap = useMobileSessionTreeStore((state) => state.projectExpanded);
const worktreeExpandedMap = useMobileSessionTreeStore((state) => state.worktreeExpanded);
@@ -895,12 +945,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
// Swipe-left actions: which row has its actions revealed, and whether its
// Swipe-right actions: which row has its actions revealed, and whether its
// delete button is armed (two-step). One row at a time.
const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(null);
// Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) —
// Swipe-right actions on group headers (`project:{id}` / `wt:{bucketKey}`) —
// separate from session rows, but mutually exclusive with them.
const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null);
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(null);
@@ -910,6 +960,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
} | null>(null);
// Bumped to force a re-list of worktrees (e.g. after one is deleted in the editor).
const [worktreeRefreshKey, setWorktreeRefreshKey] = React.useState(0);
const [sortPanelOpen, setSortPanelOpen] = React.useState(false);
const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false);
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState<string | null>(null);
@@ -995,21 +1046,27 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const projectsMeta = React.useMemo<ProjectMeta[]>(
() =>
projects.map((project) => ({
id: project.id,
label: project.label?.trim() || getProjectLabel(project.path),
path: normalizePath(project.path),
icon: project.icon,
color: project.color,
iconImage: project.iconImage,
iconBackground: project.iconBackground,
isGitRepo: gitProjectPaths.has(normalizePath(project.path)),
worktrees: orderWorktrees(
worktreeOrderByProject[project.id],
worktreesByProject.get(normalizePath(project.path)) ?? [],
),
})),
[gitProjectPaths, projects, worktreeOrderByProject, worktreesByProject],
sortProjectsByOrder(
projects.map((project) => ({
id: project.id,
label: project.label?.trim() || getProjectLabel(project.path),
path: normalizePath(project.path),
icon: project.icon,
color: project.color,
iconImage: project.iconImage,
iconBackground: project.iconBackground,
isGitRepo: gitProjectPaths.has(normalizePath(project.path)),
worktrees: orderWorktrees(
worktreeOrderByProject[project.id],
worktreesByProject.get(normalizePath(project.path)) ?? [],
),
addedAt: project.addedAt,
lastOpenedAt: project.lastOpenedAt,
})),
projectSortOrder,
manualProjectOrder,
),
[gitProjectPaths, manualProjectOrder, projectSortOrder, projects, worktreeOrderByProject, worktreesByProject],
);
/**
@@ -1345,6 +1402,16 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// The order is a shared setting, so persist it the same way the desktop
// sidebar does — picking it here follows the user to their other surfaces.
const handleProjectSortChange = (order: ProjectSortOrder) => {
setProjectSortOrder(order);
void updateDesktopSettings({ sidebarProjectSortOrder: order });
// Dragging projects rewrites the manual order; it means nothing while the
// list is sorted by something else.
if (order !== 'manual') setEditingOrder(false);
};
const handleReorderDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -1382,6 +1449,15 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onOpenChange(false);
};
// Same contract as the desktop sidebar's per-project "+": the draft carries
// the project and its directory, so the app's current directory is not
// switched out from under the session that is still open behind the drawer.
const handleNewSessionInProject = (project: ProjectMeta) => {
setActiveProjectIdOnly(project.id);
openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path });
onOpenChange(false);
};
const filteredNodes = React.useMemo(() => {
if (!normalizedQuery) return projectNodes;
return projectNodes.filter((node) => {
@@ -1413,22 +1489,33 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
);
}, [normalizedQuery, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
.map((project) => ({
...project,
sessionCount: sessions.filter((session) => {
if (getParentId(session)) return false;
const directory = normalizePath(getSessionDirectory(session));
return projectMatchesExactDirectory(project, directory);
}).length,
}));
}, [normalizedQuery, projectsMeta, sessions]);
const searchProjectMatches = React.useMemo<ProjectMeta[]>(() => {
if (!normalizedQuery) return [];
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]);
}, [normalizedQuery, projectsMeta]);
const hasNoMatches =
normalizedQuery && searchSessionMatches.length === 0 && searchProjectMatches.length === 0;
const canEditOrder = !normalizedQuery && projectsMeta.length > 1;
// Drag order IS the manual order: offering it under another sort would let
// the user rearrange a list that is about to be re-sorted anyway.
const canEditOrder = !normalizedQuery && projectsMeta.length > 1 && projectSortOrder === 'manual';
// Sorting lives in the header next to reordering — the two answer the same
// question about the list, and a permanent row of modes above it would cost
// a project row for a setting touched once a month.
const sortToggle = !editingOrder && !normalizedQuery && projectsMeta.length > 1 ? (
<Button
type="button"
variant="chip"
size="sm"
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
title={t('sessions.sidebar.header.actions.sortProjects')}
onClick={() => setSortPanelOpen(true)}
style={{ touchAction: 'manipulation' }}
>
<Icon name="equalizer-2" className="size-4" />
</Button>
) : null;
const editToggle = canEditOrder ? (
<Button
@@ -1473,12 +1560,16 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</Button>
) : null;
// The new-session button keeps the outer right edge whatever else is showing:
// it is the one action people reach for without looking, so it must not slide
// around as the icons beside it come and go.
const trailingActions =
newChatButton || addProjectButton || editToggle ? (
newChatButton || addProjectButton || sortToggle || editToggle ? (
<>
{newChatButton}
{addProjectButton}
{sortToggle}
{editToggle}
{newChatButton}
</>
) : null;
@@ -1587,16 +1678,15 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<span className="block min-w-0 flex-1 truncate typography-ui-label text-foreground">
{project.label}
</span>
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{project.sessionCount}
</span>
</button>
{project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(project.id)}
/>
<NewWorktreeIconButton onClick={() => handleNewWorktree(project.id)} />
) : null}
<NewSessionIconButton
className="mr-2"
label={t('mobile.sessions.newSessionInProjectAria', { label: project.label })}
onClick={() => handleNewSessionInProject(project)}
/>
</div>
))}
</div>
@@ -1703,19 +1793,6 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
actions={(
<>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
@@ -1742,6 +1819,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</>
)}
>
@@ -1768,17 +1858,15 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
{node.project.label}
</span>
{node.isActive ? <ActiveDot ariaLabel={t('mobile.sessions.activeProjectAria')} /> : null}
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{node.totalSessions}
</span>
</button>
{node.project.isGitRepo ? (
<NewWorktreeIconButton
className="mr-2"
onClick={() => handleNewWorktree(node.project.id)}
/>
<NewWorktreeIconButton onClick={() => handleNewWorktree(node.project.id)} />
) : null}
<NewSessionIconButton
className="mr-2"
label={t('mobile.sessions.newSessionInProjectAria', { label: node.project.label })}
onClick={() => handleNewSessionInProject(node.project)}
/>
</div>
</MobileSwipeActionsRow>
@@ -1965,6 +2053,33 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onClose={() => setEditingProjectId(null)}
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
/>
<MobileOverlayPanel
open={sortPanelOpen}
onClose={() => setSortPanelOpen(false)}
title={t('sessions.sidebar.header.actions.sortProjects')}
>
<div className="flex flex-col">
{PROJECT_SORT_OPTIONS.map(([order, labelKey]) => (
<button
key={order}
type="button"
className={cn(
'flex min-h-11 w-full items-center justify-between rounded-lg px-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
projectSortOrder === order ? 'text-primary' : 'text-foreground',
)}
onClick={() => {
handleProjectSortChange(order);
setSortPanelOpen(false);
}}
style={{ touchAction: 'manipulation' }}
>
<span className="typography-ui-label">{t(labelKey)}</span>
{projectSortOrder === order ? <Icon name="check" className="size-4" /> : null}
</button>
))}
</div>
</MobileOverlayPanel>
{worktreeToDelete ? (
<MobileDeleteWorktreeDialog
open
@@ -1998,6 +2113,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<MobileSessionsDrawerContainer
open={open}
onClose={() => onOpenChange(false)}
// The mirror of the swipe that opened the drawer closes it again —
// except while a row has its actions out: then the same swipe is the
// user putting those away, so it only clears them.
onSwipeClose={() => {
if (revealedSessionId || revealedRowId) {
setRevealedSessionId(null);
setRevealedRowId(null);
setConfirmingDeleteSessionId(null);
setConfirmingRemoveProjectId(null);
return;
}
onOpenChange(false);
}}
ariaLabel={t('mobile.sessions.sheet.title')}
>
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
@@ -2030,8 +2158,9 @@ const DRAWER_ENTER_DURATION_MS = 320;
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
/** Full-width left drawer for the phone sessions list: covers the whole app
and slides in from the left edge. Closes via the header X, Escape, or the
Android back button (handled by MobileShell).
and slides in from the left edge. Closes via the header X, a right-edge
swipe back toward the left (the mirror of the gesture that opened it),
Escape, or the Android back button (handled by MobileShell).
Stays MOUNTED while closed (parked off-screen, hidden): the sessions
sheet's project/worktree state stays warm, so reopening shows the tree
@@ -2040,10 +2169,14 @@ const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
const MobileSessionsDrawerContainer: React.FC<{
open: boolean;
onClose: () => void;
/** What the closing edge swipe does; the drawer's owner may want it to undo
a lighter state first. Falls back to `onClose`. */
onSwipeClose?: () => void;
ariaLabel: string;
children: React.ReactNode;
}> = ({ open, onClose, ariaLabel, children }) => {
}> = ({ open, onClose, onSwipeClose, ariaLabel, children }) => {
const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open);
@@ -2051,6 +2184,18 @@ const MobileSessionsDrawerContainer: React.FC<{
React.useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const onSwipeCloseRef = React.useRef(onSwipeClose);
React.useEffect(() => {
onSwipeCloseRef.current = onSwipeClose;
}, [onSwipeClose]);
// Swipe from the drawer's right edge back toward the left = close, the
// reverse of the left-edge swipe that opened it from the chat. Rows inside
// reveal their actions in the opposite direction, so the two never fight.
useEdgeSwipe(drawerRef, {
enabled: open,
onRightEdgeSwipe: () => (onSwipeCloseRef.current ?? onCloseRef.current)(),
});
if (typeof document !== 'undefined' && !rootRef.current) {
let root = document.getElementById(DRAWER_ROOT_ID);
@@ -2091,6 +2236,7 @@ const MobileSessionsDrawerContainer: React.FC<{
return createPortal(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
+19 -6
View File
@@ -17,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
const DRAWER_ROOT_ID = 'mobile-surface-root';
const ENTER_DELAY_MS = 16;
@@ -96,8 +97,9 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM
beside the chat (tablet, landscape). The caller owns the width and the
open/close animation there; this component only fills it.
Closes via the header X, Escape (unless the terminal tab owns the keys), or
the Android back button (handled by MobileShell). */
Closes via the header X, a left-edge swipe back toward the right (the
mirror of the gesture that opened it), Escape (unless the terminal tab owns
the keys), or the Android back button (handled by MobileShell). */
export const MobileWorkspaceDrawer: React.FC<{
open: boolean;
onClose: () => void;
@@ -113,6 +115,7 @@ export const MobileWorkspaceDrawer: React.FC<{
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
const { t } = useI18n();
const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open);
@@ -125,6 +128,15 @@ export const MobileWorkspaceDrawer: React.FC<{
tabRef.current = tab;
}, [tab]);
// Swipe from the drawer's left edge back toward the right = close, the
// reverse of the right-edge swipe that opened it from the chat. Only the
// full-cover drawer has an edge to grab; the tablet panel is closed from the
// header instead.
useEdgeSwipe(drawerRef, {
enabled: variant === 'drawer' && open,
onLeftEdgeSwipe: () => onCloseRef.current(),
});
// Tabs the user has actually opened — their panes stay mounted afterwards.
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
React.useEffect(() => {
@@ -224,14 +236,14 @@ export const MobileWorkspaceDrawer: React.FC<{
{visitedTabs.has('changes') ? (
<div
// A newly requested per-file diff remounts the pane so
// initialDiffPath applies; plain reopens keep the state.
// initialDiff applies; plain reopens keep the state.
key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'}
className={cn('h-full', tab !== 'changes' && 'hidden')}
>
<ErrorBoundary>
<MobileChangesSurface
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
visible={open && tab === 'changes'}
initialDiff={pendingChangesDiff}
/>
</ErrorBoundary>
</div>
@@ -278,11 +290,12 @@ export const MobileWorkspaceDrawer: React.FC<{
return createPortal(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={t('mobile.header.openWorkspaceAria')}
aria-hidden={!open}
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{
paddingTop: 'var(--oc-safe-area-top, 0px)',
// Settled state drops the transform entirely so the drawer isn't kept
+3
View File
@@ -9,6 +9,7 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
@@ -114,6 +115,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
@@ -134,6 +136,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<AppLinkConfirmDialog />
<SharedTrustConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
+11 -4
View File
@@ -1,11 +1,12 @@
import React from 'react';
/**
* Native-feeling edge swipes on the mobile chat: start a horizontal swipe from
* Native-feeling edge swipes on the mobile shell: start a horizontal swipe from
* the very left/right screen edge and drag toward the centre.
*
* - Left edge centre = open the sessions drawer
* - Right edge centre = open the most recent overflow surface
* On the chat that opens a drawer (left edge sessions, right edge
* workspace); on an open drawer the mirrored swipe closes it (sessions drawer
* closes from the right edge, workspace drawer from the left edge).
*
* Only `touchstart`/`touchend` are observed (both passive), so this never
* interferes with vertical chat scrolling or the horizontal scroll inside code
@@ -27,6 +28,9 @@ export interface EdgeSwipeOptions {
onLeftEdgeSwipe?: () => void;
/** Swipe that started at the right edge and travelled left. */
onRightEdgeSwipe?: () => void;
/** Defaults to on. Flipping it re-attaches the listeners, which is what a
drawer needs: its element only exists (or only matters) while open. */
enabled?: boolean;
}
export const useEdgeSwipe = (
@@ -37,7 +41,10 @@ export const useEdgeSwipe = (
const optionsRef = React.useRef(options);
optionsRef.current = options;
const enabled = options.enabled ?? true;
React.useEffect(() => {
if (!enabled) return;
const element = ref.current;
if (!element) return;
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
@@ -87,5 +94,5 @@ export const useEdgeSwipe = (
element.removeEventListener('touchstart', onTouchStart);
element.removeEventListener('touchend', onTouchEnd);
};
}, [ref]);
}, [enabled, ref]);
};
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="24" height="24">
<title>Cline</title>
<path d="m39.06 22.594-2.403-4.826V14.99c0-4.606-3.697-8.336-8.257-8.336h-4.107c.297-.61.46-1.297.46-2.021 0-2.56-2.06-4.632-4.605-4.632s-4.606 2.072-4.606 4.632c0 .724.163 1.41.46 2.02h-4.107c-4.56 0-8.256 3.731-8.256 8.337v2.78l-2.454 4.81a1.7 1.7 0 0 0 0 1.545l2.454 4.758v2.78c0 4.605 3.697 8.336 8.256 8.336H28.4c4.56 0 8.257-3.73 8.257-8.337v-2.779l2.399-4.774a1.7 1.7 0 0 0 .004-1.516m-21.424 3.932c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79zm12.142 0c0 2.093-1.688 3.79-3.769 3.79-2.08 0-3.768-1.697-3.768-3.79V19.79c0-2.093 1.688-3.79 3.768-3.79s3.769 1.697 3.769 3.79z"/>
</svg>

After

Width:  |  Height:  |  Size: 773 B

@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
const [isAnnotating, setIsAnnotating] = React.useState(false);
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
const [zoomLevel, setZoomLevel] = React.useState(0);
const zoomLevelRef = React.useRef(0);
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the viewport
@@ -580,6 +581,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
const applyZoom = React.useCallback((level: number) => {
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
zoomLevelRef.current = next;
setZoomLevel(next);
try {
webviewRef.current?.setZoomLevel(next);
@@ -588,6 +590,20 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
}
}, []);
React.useEffect(() => {
const handleZoom = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const action = event.detail;
const webview = webviewRef.current;
if (!webview || document.activeElement !== webview) return;
if (action === 'zoom-in') applyZoom(zoomLevelRef.current + ZOOM_STEP);
else if (action === 'zoom-out') applyZoom(zoomLevelRef.current - ZOOM_STEP);
else if (action === 'zoom-reset') applyZoom(0);
};
window.addEventListener('openchamber:zoom', handleZoom);
return () => window.removeEventListener('openchamber:zoom', handleZoom);
}, [applyZoom]);
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
void invokeDesktopCommand('desktop_browser_clear_data', {
partition: BROWSER_PARTITION,
@@ -171,10 +171,8 @@ type ChatViewportProps = {
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange: () => void;
renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean;
@@ -215,10 +213,8 @@ const ChatViewport = React.memo(({
scrollRef,
messageListRef,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onListMetricsChange,
onTimelineDataChange,
renderedMessages,
isLoadingOlder,
@@ -499,14 +495,12 @@ const ChatViewport = React.memo(({
endPinningReleased={endPinningReleased}
directory={directory}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
// Zero end inset: the footer spacer already reserves the
// zone the floating status row covers; adding its height
// again produced a double-tall blank band at rest.
composerOverlayHeight={0}
onIsAtEndChange={onIsAtEndChange}
onListMetricsChange={onListMetricsChange}
onTimelineDataChange={onTimelineDataChange}
listHeader={listHeader}
listFooter={listFooter}
@@ -543,6 +537,7 @@ const ChatViewport = React.memo(({
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay
&& prev.scrollToBottom === next.scrollToBottom
&& prev.onListMetricsChange === next.onListMetricsChange
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealWaited === next.revealWaited
&& prev.revealGate === next.revealGate
@@ -1106,24 +1101,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
}, []);
const lastUserMessageId = React.useMemo(() => {
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
const message = sessionMessages[index];
if (message.info.role === 'user') {
return message.info.id;
}
}
return null;
}, [sessionMessages]);
const {
scrollRef,
scrollNode,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onListMetricsChange,
onManualNavigation,
onTimelineDataChange,
goToBottom,
@@ -1138,7 +1121,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
currentSessionKey,
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
sessionIsWorking,
revealGate,
onActiveTurnChange: handleActiveTurnChange,
@@ -1549,10 +1531,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
directory={effectiveSessionDirectory}
scrollRef={scrollRef}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
onIsAtEndChange={onIsAtEndChange}
onListMetricsChange={onListMetricsChange}
onTimelineDataChange={onTimelineDataChange}
messageListRef={messageListRef}
renderedMessages={timelineController.renderedMessages}
File diff suppressed because it is too large Load Diff
@@ -68,9 +68,11 @@ const SortableChip: React.FC<{
item: ResolvedStarter;
onSubmit: (starter: ResolvedStarter) => void;
onRemove: () => void;
/** Project chips only: move the starter into the team's shared file, or back out of it. */
onToggleShared?: () => void;
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
hideRemove?: boolean;
}> = ({ item, onSubmit, onRemove, hideRemove }) => {
}> = ({ item, onSubmit, onRemove, onToggleShared, hideRemove }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
@@ -92,13 +94,27 @@ const SortableChip: React.FC<{
{...attributes}
{...listeners}
onClick={() => onSubmit(item)}
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 typography-ui-label text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
style={chipStyle}
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
>
<Icon name={item.icon} className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
<span className="whitespace-nowrap">{item.label}</span>
</button>
{hideRemove ? null : (
{onToggleShared && !hideRemove ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleShared(); }}
aria-label={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
title={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
className="absolute -left-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
style={chipStyle}
>
<Icon name={item.shared ? 'user' : 'team'} className="h-2.5 w-2.5" />
</button>
) : null}
{/* A shared starter is the team's: it leaves only through the repo file. */}
{hideRemove || item.shared ? null : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
@@ -118,8 +134,9 @@ const StarterGroup: React.FC<{
items: ResolvedStarter[];
onSubmit: (starter: ResolvedStarter) => void;
onRemove: (item: ResolvedStarter) => void;
onToggleShared?: (item: ResolvedStarter) => void;
hideRemove?: boolean;
}> = ({ items, onSubmit, onRemove, hideRemove }) => (
}> = ({ items, onSubmit, onRemove, onToggleShared, hideRemove }) => (
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map((item) => (
<SortableChip
@@ -127,6 +144,7 @@ const StarterGroup: React.FC<{
item={item}
onSubmit={onSubmit}
onRemove={() => onRemove(item)}
onToggleShared={onToggleShared ? () => onToggleShared(item) : undefined}
hideRemove={hideRemove}
/>
))}
@@ -256,7 +274,7 @@ const AddStarterPicker: React.FC<{
* ignored.
*/
const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder, shareStarter, unshareStarter } = useDraftStarters();
const { isMobile } = useDeviceInfo();
const [isDragging, setIsDragging] = React.useState(false);
@@ -320,6 +338,7 @@ const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, cl
items={project}
onSubmit={onSubmit}
onRemove={(item) => removeStarter('project', item.ref)}
onToggleShared={(item) => (item.shared ? unshareStarter(item.ref) : shareStarter(item.ref))}
hideRemove={isMobile}
/>
) : null}
@@ -326,6 +326,50 @@ afterAll(() => {
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('preserves disclosure choices through streaming, settlement, and redecorating', async () => {
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
const prefix = 'Introduction\n\n<details><summary>Review</summary>\n\n';
const render = async (content: string, streaming: boolean) => {
await act(async () => {
root.render(<MarkdownRenderer content={content} messageId="disclosures" isAnimated={false} isStreaming={streaming} enableFileReferences={false} />);
await waitForSettledEffects();
});
await act(async () => waitForSettledEffects());
};
try {
await render(`${prefix}First`, true);
const first = host.querySelector<HTMLDetailsElement>('details');
expect(first).not.toBeNull();
expect(first?.open).toBe(false);
expect(first?.querySelector('summary [data-md-disclosure-icon] use')?.getAttribute('href')).toBe('#oc-arrow-right-s');
if (!first) throw new Error('Expected disclosure');
first.open = true;
for (let count = 1; count <= 5; count += 1) {
await render(`${prefix}First\n\n${'More text. '.repeat(count)}`, true);
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(true);
}
const settled = `${prefix}First\n\n</details>\n\n<details open><summary>Second</summary>\n\nBody\n\n</details>`;
await render(settled, false);
const disclosures = host.querySelectorAll<HTMLDetailsElement>('details');
expect(disclosures).toHaveLength(2);
expect(disclosures[0]?.open).toBe(true);
expect(disclosures[1]?.open).toBe(true);
disclosures[1]!.open = false;
// The fixture supplies a fresh theme/translation context on each render,
// exercising whole-block replacement with unchanged source as well.
await render(settled, false);
expect(host.querySelectorAll<HTMLDetailsElement>('details')[0]?.open).toBe(true);
expect(host.querySelectorAll<HTMLDetailsElement>('details')[1]?.open).toBe(false);
expect(host.querySelectorAll('summary [data-md-disclosure-icon]')).toHaveLength(2);
await render('<details><summary>Different</summary>\n\nNew body\n\n</details>', false);
expect(host.querySelector<HTMLDetailsElement>('details')?.open).toBe(false);
} finally {
await act(async () => root.unmount());
}
});
test('fixes body-sized table columns once the stream settles', async () => {
const content = [
'| An intentionally oversized header | Another oversized header | A third oversized header |',
@@ -383,6 +427,9 @@ describe('MarkdownRenderer DOM mount performance contract', () => {
expect(table?.classList.contains('min-w-full')).toBe(false);
expect(table?.classList.contains('w-full')).toBe(false);
expect(table?.parentElement?.classList.contains('overflow-x-auto')).toBe(true);
const wrapper = table?.closest('[data-markdown="table-wrapper"]');
expect(wrapper?.classList.contains('w-fit')).toBe(true);
expect(wrapper?.classList.contains('max-w-full')).toBe(true);
expect(cells.length).toBeGreaterThan(0);
expect(cells.every((cell) => cell.classList.contains('min-w-[120px]'))).toBe(true);
expect(cells.every((cell) => cell.classList.contains('max-w-[320px]'))).toBe(true);
@@ -1019,6 +1019,12 @@ const useMorphdomMarkdown = ({
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Capture before block reconciliation: streaming completion changes the
// wrapper layout, and theme changes can replace entire decorated blocks.
// Match by disclosure order plus heading so unrelated replacements cannot
// inherit the previous disclosure's state. No persistent/global state.
const disclosureStates = Array.from(target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]'))
.map((details) => ({ summary: details.querySelector('summary')?.textContent, open: details.open }));
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
@@ -1081,7 +1087,13 @@ const useMorphdomMarkdown = ({
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
onBeforeElUpdated: (fromEl, toEl) => {
if (fromEl.matches('details[data-md-details]') && toEl.matches('details[data-md-details]')
&& fromEl.querySelector('summary')?.textContent === toEl.querySelector('summary')?.textContent) {
toEl.toggleAttribute('open', fromEl.hasAttribute('open'));
}
return !fromEl.isEqualNode(toEl);
},
});
el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
@@ -1102,6 +1114,14 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
if (disclosureStates.length > 0) {
target.querySelectorAll<HTMLDetailsElement>('details[data-md-details]').forEach((details, index) => {
const previous = disclosureStates[index];
if (previous && previous.summary === details.querySelector('summary')?.textContent) {
details.open = previous.open;
}
});
}
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
+72 -79
View File
@@ -3,8 +3,11 @@ import type { Part } from '@opencode-ai/sdk/v2';
import { LegendList, type LegendListRef } from '@legendapp/list/react';
import ChatMessage from './ChatMessage';
import { filterVisibleParts, isEmptyTextPart } from './message/partUtils';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import { LiveTurnActivity } from './components/LiveTurnActivity';
import { getTurnsWithLaterAssistant, hasLiveActivity } from './lib/turns/liveActivity';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
@@ -20,7 +23,7 @@ import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionPartsForMessages } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { resolveChatListAnchoredEndSpace, resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import { resolveTimelineIsAtEnd } from './lib/scroll/timelineScrollAnchoring';
import {
USER_SHELL_MARKER,
isUserShellMarkerMessage,
@@ -43,8 +46,6 @@ const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
// • `maintainVisibleContentPosition` preserves the read position when older
// history is prepended, replacing the manual anchor-hold and the mobile
// quiet-window prepend deferral.
// • `anchoredEndSpace` reserves the tail space that parks a just-sent
// message near the top of the viewport.
const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
// Anchor hold for an explicit viewport restore (session re-entry): row
@@ -54,9 +55,6 @@ const TIMELINE_ESTIMATED_ENTRY_SIZE = 320;
const ANCHOR_HOLD_STABLE_FRAMES = 30;
const ANCHOR_HOLD_MAX_FRAMES = 180;
// Reserved tail space that parks an anchored row near the top of the viewport.
// `onReady` fires once the list has measured the anchor, `onSizeChanged` when
// the reserved size is recomputed.
// Presentation-only props forwarded to the scroll container the list renders.
// Deliberately narrow: the list owns scroll and layout callbacks on that
// element, so only styling, focus and click-through are caller-controlled.
@@ -69,13 +67,6 @@ type TimelineScrollContainerProps = {
'data-scroll-shadow'?: string;
};
type TimelineAnchoredEndSpace = {
anchorIndex: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
onSizeChanged?: (size: number) => void;
};
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
const handlerRef = React.useRef(handler);
React.useEffect(() => {
@@ -324,13 +315,9 @@ interface MessageListProps {
// True while a real gesture owns the scroll; releases the list's own
// end pinning so the state machine, not the library heuristic, decides.
endPinningReleased?: boolean;
// The anchored row is identified by message id; the index it maps to is a
// property of the row model, which only this component knows.
anchorMessageId?: string | null;
onAnchorReady?: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged?: (messageId: string) => void;
composerOverlayHeight?: number;
onIsAtEndChange?: (isAtEnd: boolean) => void;
onListMetricsChange?: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange?: () => void;
// Content that used to sit as siblings of the list inside the scroll
// container. The list owns that container now, so they render as its
@@ -358,9 +345,10 @@ type RenderEntry =
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry };
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; hasLaterAssistant?: boolean; nextEntryFirstMessage?: ChatMessageEntry };
type TurnUiState = { isExpanded: boolean };
type TurnUiState = { isExpanded: boolean; isLiveExpanded?: boolean };
type ToggleTurnGroup = (turnId: string, mode?: 'sorted' | 'live') => void;
@@ -427,12 +415,13 @@ MessageRow.displayName = 'MessageRow';
interface TurnBlockProps {
turn: TurnRecord;
hasLaterAssistant?: boolean;
isLastTurn: boolean;
nextEntryFirstMessage?: ChatMessageEntry;
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
scrollToBottom?: () => void;
stickyUserHeader?: boolean;
@@ -445,6 +434,7 @@ interface TurnBlockProps {
const TurnBlock = React.memo(({
turn,
hasLaterAssistant = false,
isLastTurn,
nextEntryFirstMessage,
sessionIsWorking,
@@ -462,6 +452,7 @@ const TurnBlock = React.memo(({
}: TurnBlockProps) => {
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
const userMessageHidden = React.useMemo(
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
[planModeEnabled, turn.userMessage]
@@ -470,6 +461,9 @@ const TurnBlock = React.memo(({
const handleToggleTurnGroup = React.useCallback(() => {
onToggleTurnGroup(turn.turnId);
}, [onToggleTurnGroup, turn.turnId]);
const handleToggleLiveActivity = React.useCallback(() => {
onToggleTurnGroup(turn.turnId, 'live');
}, [onToggleTurnGroup, turn.turnId]);
const messageOrder = React.useMemo(() => {
const ordered = [turn.userMessage, ...turn.assistantMessages];
@@ -653,6 +647,9 @@ const TurnBlock = React.memo(({
activityOwnerMessageId,
isFirstAssistantInTurn: isFirstAssistant,
isLastAssistantInTurn: isLastAssistant,
hasEarlierAssistantText: chatRenderMode === 'live' && isLastAssistant && visibleAssistantMessages.some((assistant, index) => (
index < assistantIndex && filterVisibleParts(assistant.parts).some((part) => part.type === 'text' && !isEmptyTextPart(part))
)),
isLatestTurn: isLastTurn,
isWorking: isLastTurn && sessionIsWorking && (
chatRenderMode === 'sorted'
@@ -736,6 +733,15 @@ const TurnBlock = React.memo(({
turn={renderableTurn}
stickyUserHeader={stickyUserHeader && !userMessageHidden}
renderMessage={renderMessage}
assistantContent={chatRenderMode === 'live' && !defaultActivityExpanded && hasLiveActivity(turn, showReasoningTraces) ? (
<LiveTurnActivity
turn={renderableTurn}
hasLaterAssistant={hasLaterAssistant}
expanded={turnUiState.isLiveExpanded === true}
onToggle={handleToggleLiveActivity}
renderMessage={renderMessage}
/>
) : undefined}
/>
);
});
@@ -789,7 +795,7 @@ interface MessageListEntryProps {
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
@@ -845,6 +851,7 @@ const MessageListEntry = React.memo(({
return (
<TurnBlock
turn={entry.turn}
hasLaterAssistant={entry.hasLaterAssistant}
isLastTurn={entry.isLastTurn}
nextEntryFirstMessage={entry.nextEntryFirstMessage}
sessionIsWorking={sessionIsWorking}
@@ -873,7 +880,7 @@ type TimelineRowContextValue = {
stickyUserHeader: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -951,14 +958,9 @@ type TimelineListProps = {
streamingTailKey: string | null;
registerList: (list: LegendListRef | null) => void;
endPinningReleased: boolean;
anchoredEndSpace?: {
anchorIndex: number;
anchorOffset?: number;
onReady?: (info: { anchorIndex: number | undefined; anchorKey: string | undefined; size: number }) => void;
onSizeChanged?: (size: number) => void;
};
composerOverlayHeight: number;
onIsAtEndChange: (isAtEnd: boolean) => void;
onListMetricsChange: (metrics: { readonly footerSize: number }) => void;
onTimelineDataChange: () => void;
listHeader?: React.ReactNode;
listFooter?: React.ReactNode;
@@ -970,9 +972,9 @@ const TimelineList = React.memo(({
entries,
registerList,
endPinningReleased,
anchoredEndSpace,
composerOverlayHeight,
onIsAtEndChange,
onListMetricsChange,
onTimelineDataChange,
listHeader,
listFooter,
@@ -1064,33 +1066,31 @@ const TimelineList = React.memo(({
// animations); recycling a container into a different row would
// carry that state across.
recycleItems={false}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={composerOverlayHeight}
// While a turn is anchored, the reserved end space — not the
// live edge — defines where the viewport rests.
// Also released while the width resizes: re-pinning against
// rows that are still re-measuring shakes the pinned
// viewport; once the resize settles the owning hook
// re-asserts the end for a streaming session and releases
// the pin for an idle one.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
// Live only while the session streams: outside a stream the
// owning hook keeps a pinned reader on the end with same-frame
// writes, and the list's own correction runs a frame later
// against a content length that can still be stale (a
// re-wrap, a late measurement) — that is the visible bounce
// an idle reader saw on every panel toggle. Also off while the
// width resizes, where the hook holds the measured end itself.
maintainScrollAtEnd={!streamingAutoFollowEnabled || !rowContext.sessionIsWorking || isWidthResizing || endPinningReleased
? false
// Animated only while the session actively streams: there
// the block-step growth turns each correction into a glide
// and reveal + scroll read as one motion. Outside of a live
// stream — opening a historical session, late measurements —
// corrections must be instant: an animated catch-up scrolls
// visibly through the whole conversation on open, and an
// in-flight glide can supersede explicit navigation.
// Animated: the block-step growth turns each correction
// into a glide and reveal + scroll read as one motion.
: {
animated: rowContext.sessionIsWorking,
animated: true,
on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true },
}}
// Prepending older history must not move what the user is
// reading. Size restoration applies only during a width
// resize see the observer above.
maintainVisibleContentPosition={{ data: true, size: isWidthResizing }}
// resize (see the observer above) and only for a reader who
// left the end: a pinned reader is held on the end by the
// owning hook, and compensating the rows above them would pull
// the viewport away from it.
maintainVisibleContentPosition={{ data: true, size: isWidthResizing && endPinningReleased }}
onScroll={handleScroll}
onMetricsChange={onListMetricsChange}
ListHeaderComponent={header}
ListFooterComponent={footer}
{...scrollContainerProps}
@@ -1109,7 +1109,7 @@ const StreamingTailContent: React.FC<{
sessionIsWorking: boolean;
defaultActivityExpanded: boolean;
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
onToggleTurnGroup: ToggleTurnGroup;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
@@ -1183,11 +1183,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
directory,
registerList,
endPinningReleased = false,
anchorMessageId = null,
onAnchorReady,
onAnchorSizeChanged,
composerOverlayHeight = 0,
onIsAtEndChange,
onListMetricsChange,
onTimelineDataChange,
listHeader,
listFooter,
@@ -1215,13 +1213,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
React.useEffect(() => {
setTurnUiStates(new Map());
}, [activityRenderMode]);
}, [activityRenderMode, sessionKey]);
const toggleTurnGroup = React.useCallback((turnId: string) => {
const toggleTurnGroup = React.useCallback((turnId: string, mode: 'sorted' | 'live' = 'sorted') => {
setTurnUiStates((previous) => {
const next = new Map(previous);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { isExpanded: !current.isExpanded });
next.set(turnId, mode === 'live'
? { ...current, isLiveExpanded: !current.isLiveExpanded }
: { ...current, isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
@@ -1314,6 +1314,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
planModeEnabled,
});
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
const tailHasAssistant = Boolean(streamingTurn?.assistantMessages.length);
const turnsWithLaterAssistant = React.useMemo(() => {
if (chatRenderMode !== 'live' || defaultActivityExpanded) return new Set<string>();
const retired = getTurnsWithLaterAssistant(staticTurns);
if (tailHasAssistant) {
for (const turn of staticTurns) retired.add(turn.turnId);
}
return retired;
}, [chatRenderMode, defaultActivityExpanded, staticTurns, tailHasAssistant]);
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
const staticEntryUngroupedIds = hasUngroupedStaticEntries ? projection.ungroupedMessageIds : EMPTY_UNGROUPED_MESSAGE_IDS;
const staticRenderEntries = React.useMemo<RenderEntry[]>(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => {
@@ -1322,6 +1331,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
key: `turn:${turn.turnId}`,
turn,
isLastTurn: turn.turnId === projection.lastTurnId,
hasLaterAssistant: turnsWithLaterAssistant.has(turn.turnId),
}));
if (staticEntryUngroupedIds.size === 0) {
@@ -1355,7 +1365,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
});
return orderedEntries;
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns]);
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns, turnsWithLaterAssistant]);
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
if (streamingTurn) {
@@ -1435,6 +1445,10 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
onTimelineDataChange?.();
});
const stableListMetricsChange = useStableEvent((metrics: { readonly footerSize: number }) => {
onListMetricsChange?.(metrics);
});
const currentUserOrder = React.useMemo(() => {
return messages
.filter((message) => resolveMessageRole(message) === 'user')
@@ -1793,27 +1807,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
};
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]);
const anchoredEndSpace = React.useMemo<TimelineAnchoredEndSpace | undefined>(() => {
const resolved = resolveChatListAnchoredEndSpace(
allEntries,
anchorMessageId,
(entry) => (entry.kind === 'turn' ? entry.turn.userMessage.info.id : entry.message.info.id),
);
if (!resolved || !anchorMessageId) {
return undefined;
}
return {
...resolved,
onReady: (info) => {
if (info.anchorIndex === undefined) return;
onAnchorReady?.(anchorMessageId, info.anchorIndex);
},
onSizeChanged: () => {
onAnchorSizeChanged?.(anchorMessageId);
},
};
}, [allEntries, anchorMessageId, onAnchorReady, onAnchorSizeChanged]);
const rowContext = React.useMemo(() => ({
scrollToBottom: stableScrollToBottom,
stickyUserHeader,
@@ -1859,9 +1852,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
entries={allEntries}
streamingTailKey={trailingStreamingEntry?.key ?? null}
registerList={handleRegisterList}
anchoredEndSpace={anchoredEndSpace}
composerOverlayHeight={composerOverlayHeight}
onIsAtEndChange={stableIsAtEndChange}
onListMetricsChange={stableListMetricsChange}
onTimelineDataChange={stableTimelineDataChange}
listHeader={listHeader}
listFooter={listFooter}
@@ -8,14 +8,15 @@ import { useI18n } from '@/lib/i18n';
interface MobileModelButtonProps {
onOpenModel: () => void;
className?: string;
model?: { providerId: string; modelId: string } | null;
}
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className, model }) => {
const { t } = useI18n();
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const currentProvider = getCurrentProvider();
const currentModelId = useConfigStore((state) => model === undefined ? state.currentModelId : model?.modelId);
const currentProviderId = useConfigStore((state) => model === undefined ? state.currentProviderId : model?.providerId);
const providers = useConfigStore((state) => state.providers);
const currentProvider = providers.find((provider) => provider.id === currentProviderId);
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
return (
@@ -1,5 +1,6 @@
import React from 'react';
import { focusChatInput } from './composer/editor/dom';
import { MobileModelButton } from './MobileModelButton';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import type { ModelMetadata } from '@/types';
import {
@@ -17,7 +18,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
import { ModelPickerList, type ModelPickerEntry } from '@/components/model-picker/ModelPickerList';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { isDesktopShell } from '@/lib/desktop';
import { getAgentColor } from '@/lib/agentColors';
@@ -46,6 +47,7 @@ import {
shouldPreserveManualModelOverride,
} from '@/lib/messages/userModelChoice';
import { getSyncParts } from '@/sync/sync-refs';
import type { BtwSelection } from '@/stores/useBtwStore';
type IconComponent = IconName;
@@ -307,33 +309,36 @@ const formatDate = (value?: string) => {
return formatReleaseDate(parsedDate);
};
interface ModelControlsProps {
type ModelControlsProps = {
className?: string;
mobilePanel?: MobileControlsPanel;
onMobilePanelChange?: (panel: MobileControlsPanel) => void;
}
} & ({ selection?: never; sessionId?: never } | { selection: BtwSelection; sessionId: string | null });
export const ModelControls: React.FC<ModelControlsProps> = ({
className,
mobilePanel,
onMobilePanelChange,
selection,
sessionId: controlledSessionId,
}) => {
const { t } = useI18n();
const { isReady, isUnavailable } = useOpenCodeReadiness();
const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading');
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => selection ? selection.model?.providerId ?? '' : state.currentProviderId);
const currentModelId = useConfigStore((state) => selection ? selection.model?.modelId ?? '' : state.currentModelId);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
// What the picker shows is what the next send carries: an explicit choice
// when there is one, "Default" when "Default" was picked, and otherwise the
// inherited effort — showing "Default" while an inherited effort is in
// force is how a switch away from it looks like it did not stick.
const currentVariant = currentVariantSelection.override === null
let currentVariant = currentVariantSelection.override === null
? undefined
: currentVariantSelection.override ?? effectiveCurrentVariant;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
if (selection) currentVariant = selection.variant ?? undefined;
const currentAgentName = useConfigStore((state) => selection ? selection.agent : state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
const setProvider = useConfigStore((state) => state.setProvider);
@@ -354,7 +359,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const tracedReadyRef = React.useRef(false);
React.useEffect(() => {
if (tracedReadyRef.current || !isReady) return;
if (selection || tracedReadyRef.current || !isReady) return;
tracedReadyRef.current = true;
markStartupTrace('ModelControls:ready', {
providers: providers.length,
@@ -363,9 +368,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
currentAgentName,
});
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]);
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length, selection]);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
// Controlled selections never restore from the main session or its history.
const currentSessionId = useSessionUIStore((s) => selection ? null : s.currentSessionId);
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
const sync = useSync();
@@ -409,7 +415,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const addRecentModel = useUIStore((state) => state.addRecentModel);
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen);
const globalModelSelectorOpen = useUIStore((state) => !selection && state.isModelSelectorOpen);
const [localModelSelectorOpen, setLocalModelSelectorOpen] = React.useState(false);
const isModelSelectorOpen = selection ? localModelSelectorOpen : globalModelSelectorOpen;
const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -457,7 +465,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
// Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen;
const setAgentMenuOpen = selection ? setLocalModelSelectorOpen : setModelSelectorOpen;
const openAddProviderSettings = React.useCallback(() => {
setSelectedProvider(ADD_PROVIDER_ID);
setSettingsPage('providers');
@@ -524,13 +532,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Handle agent selector close behavior
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
React.useEffect(() => {
if (!isAgentSelectorOpen) {
if (!selection && !isAgentSelectorOpen) {
setAgentSearchQuery('');
if (!isCompact) {
requestAnimationFrame(focusChatInput);
}
}
}, [isAgentSelectorOpen, isCompact]);
}, [isAgentSelectorOpen, isCompact, selection]);
const selectableDesktopAgents = React.useMemo(() => {
return agents.filter((agent) => isPrimaryMode(agent.mode));
@@ -564,7 +572,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3';
const currentProvider = getCurrentProvider();
const currentProvider = selection ? providers.find((provider) => provider.id === currentProviderId) : getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const visibleProviders = React.useMemo(() => {
@@ -623,7 +631,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Compute from current model each render to avoid stale variants
// in draft/session transitions.
const availableVariants = getCurrentModelVariants();
const availableVariants = selection
? Object.keys(currentProvider?.models.find((model) => model.id === currentModelId)?.variants ?? {})
: getCurrentModelVariants();
const hasVariants = availableVariants.length > 0;
const costRows = [
@@ -650,14 +660,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Skip synthetic subagent-completion nudges — restoring from them resets a
// manual model override back to the agent default (issue #2404).
const latestLoadedUserChoice = React.useMemo(() => {
if (selection) return null;
return findLatestUserModelChoice(
currentSessionMessagesFromSync,
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
);
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
}, [currentSessionDirectory, currentSessionMessagesFromSync, selection]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
if (selection) {
if (controlledSessionId) {
saveSessionModelSelection(controlledSessionId, providerId, modelId);
if (selection.agent) saveAgentModelForSession(controlledSessionId, selection.agent, providerId, modelId);
}
return 'applied';
}
if (!providerId || !modelId) {
return 'model-missing';
}
@@ -691,7 +709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return 'applied';
},
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection],
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection, controlledSessionId, selection],
);
const getModelVariantOptions = React.useCallback((providerId: string, modelId: string) => {
@@ -739,8 +757,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
const effectiveAgentName = uiAgentName || currentAgentName;
if (currentSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
const selectionSessionId = selection ? controlledSessionId : currentSessionId;
if (selectionSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(selectionSessionId, effectiveAgentName, providerId, modelId);
// An explicit "Default" is a choice: it stops the fallbacks below.
if (savedVariant === null) {
return null;
@@ -764,9 +783,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelVariantForSession,
getModelVariantOptions,
uiAgentName,
controlledSessionId,
selection,
]);
const resolveLiveAgentName = React.useCallback(() => {
if (selection) return selection.agent;
const liveConfigAgentName = useConfigStore.getState().currentAgentName;
if (currentSessionId) {
return useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
@@ -775,7 +797,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|| currentAgentName;
}
return liveConfigAgentName || currentAgentName;
}, [currentAgentName, currentSessionId]);
}, [currentAgentName, currentSessionId, selection]);
/**
* Records `variant` as this session's effort for the model, in the same
@@ -787,6 +809,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
* user having chosen "Default".
*/
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
if (selection) {
if (controlledSessionId && selection.agent) {
saveAgentModelVariantForSession(controlledSessionId, selection.agent, providerId, modelId, variant);
}
return;
}
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
manualVariantSelectionRef.current = false;
@@ -814,6 +842,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
controlledSessionId,
selection,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
@@ -823,10 +853,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}
addRecentModel(providerId, modelId);
if (!selection) addRecentModel(providerId, modelId);
commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName);
return 'applied';
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection]);
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection, selection]);
React.useEffect(() => {
if (!currentSessionId) {
@@ -1074,7 +1104,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
]);
React.useEffect(() => {
if (!contextHydrated) {
if (selection || !contextHydrated) {
return;
}
const abortController = new AbortController();
@@ -1127,9 +1157,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelForSession,
tryApplyModelSelection,
contextHydrated,
selection,
]);
React.useEffect(() => {
if (selection) return;
if (!contextHydrated || !currentAgentName) {
manualVariantSelectionRef.current = false;
setCurrentVariant(undefined);
@@ -1199,6 +1231,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
selection,
]);
React.useEffect(() => {
@@ -1214,6 +1247,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
if (selection) return;
try {
setAgent(agentName);
addRecentAgent(agentName);
@@ -1238,6 +1272,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveSessionAgentSelection,
setAgent,
setAgentMenuOpen,
selection,
]);
const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => {
@@ -1249,6 +1284,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [agents, currentAgentName, handleAgentChange]);
const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => {
if (selection) return null;
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
? normalizeCombo(`shift+${cycleAgentShortcut}`)
: '';
@@ -1262,7 +1298,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return null;
}, [cycleAgentShortcut]);
}, [cycleAgentShortcut, selection]);
const handleProviderAndModelChange = (
providerId: string,
@@ -1284,7 +1320,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return;
}
if (!options?.applyVariant) {
if (!selection && !options?.applyVariant) {
// Add to recent models on successful selection.
addRecentModel(providerId, modelId);
}
@@ -2184,6 +2220,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
const renderModelSelector = () => {
if (isCompact && selection) {
return <MobileModelButton
model={selection.model}
onOpenModel={() => setActiveMobilePanel('model')}
className="model-controls__model-trigger flex-shrink-0"
/>;
}
const handleThinkingVariantKey = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry) => {
keyboardOwnsModelSelectionRef.current = true;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
@@ -2364,7 +2407,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture}
>
<div className="p-1 border-b border-border/40">
{!selection && <div className="p-1 border-b border-border/40">
<button
type="button"
onClick={openAddProviderSettings}
@@ -2375,9 +2418,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</span>
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
</button>
</div>
</div>}
<ModelPickerList
providers={providers as ModelPickerProvider[]}
providers={providers}
favoriteModels={favoriteModelsList}
recentModels={recentModelsList}
modelsMetadata={useConfigStore.getState().modelsMetadata}
@@ -2413,7 +2456,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-x-2 whitespace-nowrap overflow-hidden">
<span>{t('chat.modelControls.keyboardHintNavigate')}</span>
<span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>
{!selection && <span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>}
{activeHasThinkingVariants ? <span>{t('chat.modelControls.keyboardHintThinking')}</span> : null}
</div>
);
@@ -2616,6 +2659,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') event.preventDefault();
}}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
@@ -2690,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
<p className="typography-meta">{t('chat.modelControls.thinking')}: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
@@ -2875,6 +2922,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
};
const inlineMobileSelection = isMobile && Boolean(selection);
const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0',
// Only force full-width + truncation behaviors on true mobile layouts.
@@ -2888,22 +2936,24 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className={inlineClassName}>
<div
className={cn(
'flex items-center min-w-0 flex-1 justify-end',
'flex items-center min-w-0 flex-1',
inlineMobileSelection ? 'justify-start' : 'justify-end',
inlineGapClass,
isMobile && 'overflow-hidden'
)}
>
{renderVariantSelector()}
{!inlineMobileSelection && renderVariantSelector()}
{renderModelSelector()}
{renderAgentSelector()}
{inlineMobileSelection && renderVariantSelector()}
{!selection && renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()}
{!selection && renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}
{!selection && renderMobileAgentTooltip()}
</>
);
@@ -1,5 +1,5 @@
import React, { act } from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import { create } from 'zustand';
@@ -122,9 +122,9 @@ type SelectionState = {
getSessionAgentSelection: () => string | null;
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
getAgentModelVariantForSession: () => VariantChoice;
saveSessionModelSelection: () => void;
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
saveSessionAgentSelection: () => void;
saveAgentModelForSession: () => void;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
saveAgentModelVariantForSession: (
sessionId: string,
agentName: string,
@@ -206,9 +206,11 @@ mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) }));
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: passthrough,
DropdownMenu: ({ children, open }: React.PropsWithChildren<{ open?: boolean }>) => <div data-menu-open={open}>{children}</div>,
DropdownMenuContent: passthrough,
DropdownMenuItem: passthrough,
DropdownMenuItem: ({ children, onSelect }: React.PropsWithChildren<{ onSelect?: () => void }>) => (
<button onClick={onSelect}>{children}</button>
),
DropdownMenuLabel: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: passthrough,
@@ -216,7 +218,9 @@ mock.module('@/components/ui/dropdown-menu', () => ({
mock.module('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough }));
mock.module('@/components/ui/MobileOverlayPanel', () => ({
MobileOverlayPanel: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? <div>{children}</div> : null,
}));
mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
mock.module('@/components/ui/tooltip', () => ({
@@ -225,9 +229,13 @@ mock.module('@/components/ui/tooltip', () => ({
TooltipTrigger: passthrough,
}));
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({
ModelPickerList: ({ onSelect }: React.ComponentProps<typeof import('@/components/model-picker/ModelPickerList').ModelPickerList>) => (
<button onClick={() => onSelect({ providerID: PROVIDER_ID, modelID: MODEL_ID, model })}>{MODEL_ID}</button>
),
}));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModelsList: [], recentModelsList: [] }) }));
mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false }));
mock.module('@/hooks/useOpenCodeReadiness', () => ({
useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }),
@@ -304,12 +312,12 @@ const installDom = () => {
};
};
const renderModelControls = async () => {
const renderModelControls = async (props: React.ComponentProps<typeof ModelControls> = {}) => {
const dom = installDom();
const root = createRoot(dom.container);
await act(async () => root.render(
<I18nProvider>
<ModelControls />
<ModelControls {...props} />
</I18nProvider>,
));
return {
@@ -327,6 +335,7 @@ describe('ModelControls effort restore', () => {
overrideWrites.length = 0;
latestUserChoice = null;
forcePreserveManualOverride = null;
useUIStore.setState({ isMobile: false, isModelSelectorOpen: false });
useSelectionStore.setState({ savedVariant: undefined });
useConfigStore.setState({
currentProviderId: PROVIDER_ID,
@@ -341,9 +350,11 @@ describe('ModelControls effort restore', () => {
test('restores the concrete effort the session history carries', async () => {
latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' };
useUIStore.setState({ isModelSelectorOpen: true });
const { cleanup } = await renderModelControls();
const { dom, cleanup } = await renderModelControls();
try {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('true');
expect(variantWrites).toContain('low');
expect(variantWrites).not.toContain(null);
expect(useSelectionStore.getState().savedVariant).toBe('low');
@@ -403,4 +414,55 @@ describe('ModelControls effort restore', () => {
await cleanup();
}
});
for (const [isMobile, variant] of [
[false, 'low'], [true, null],
] as const) {
test(`controlled BTW selection stays independent (mobile: ${isMobile}, effort: ${variant})`, async () => {
const btwSessionId = 'btw-pending:ses_restore';
latestUserChoice = { id: 'msg-main', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' };
useUIStore.setState({ isMobile, isModelSelectorOpen: true });
useConfigStore.setState({
currentProviderId: 'main-provider', currentModelId: 'main-model',
currentVariant: 'low', currentVariantSelection: { override: undefined, inherited: 'low' },
});
const selections = useSelectionStore.getState();
const saveModel = spyOn(selections, 'saveSessionModelSelection');
const saveAgentModel = spyOn(selections, 'saveAgentModelForSession');
const saveVariant = spyOn(selections, 'saveAgentModelVariantForSession');
const { dom, cleanup } = await renderModelControls({
sessionId: btwSessionId,
selection: { model: { providerId: PROVIDER_ID, modelId: MODEL_ID }, agent: 'plan', variant },
});
try {
expect(overrideWrites).toEqual([]);
expect(variantWrites).toEqual([]);
expect(saveModel.mock.calls).toEqual([]);
expect(dom.container.querySelector('.model-controls__agent-label')).toBeNull();
expect(dom.container.querySelector('.model-controls__variant-label')?.textContent?.trim()).toBe(variant ?? 'Default');
if (!isMobile) {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('false');
}
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__model-trigger')?.click());
const modelButton = Array.from(dom.container.querySelectorAll<HTMLButtonElement>('button:not(.model-controls__model-trigger)')).find((button) => button.textContent?.trim() === MODEL_ID);
await act(async () => modelButton?.click());
expect(saveModel.mock.calls.at(-1)).toEqual([btwSessionId, PROVIDER_ID, MODEL_ID]);
expect(saveAgentModel.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID]);
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__variant-trigger')?.click());
const defaultButton = Array.from(dom.container.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Default');
await act(async () => defaultButton?.click());
expect(saveVariant.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID, null]);
const config = useConfigStore.getState();
expect([config.currentProviderId, config.currentModelId, config.currentAgentName, config.currentVariant])
.toEqual(['main-provider', 'main-model', AGENT, 'low']);
expect(overrideWrites).toEqual([]);
} finally {
await cleanup();
for (const write of [saveModel, saveAgentModel, saveVariant]) write.mockRestore();
useSelectionStore.setState(selections);
}
});
}
});
@@ -1,6 +1,7 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { isIMECompositionEvent } from '@/lib/ime';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
@@ -40,11 +41,13 @@ const IDLE_SESSION_STATUS = { type: 'idle' as const };
* 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 }> = ({
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState; onExit: () => void }> = ({
parentSessionId,
panel,
onExit,
}) => {
const { t } = useI18n();
useEscapeToExit(onExit, !panel.collapsed && Boolean(panel.pending || panel.creating || panel.btwSessionId));
if (panel.btwSessionId && panel.btwDirectory) {
return (
@@ -54,7 +57,6 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
@@ -63,7 +65,7 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<BtwFrame>
<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>
@@ -72,6 +74,27 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
);
}
if (panel.pending) {
return (
<BtwFrame
draftHint={t('chat.btw.draftHint')}
collapsed={panel.collapsed}
actions={(
<Button
type="button"
variant="ghost"
size="icon"
onClick={onExit}
aria-label={t('chat.btw.cancelAria')}
title={t('chat.btw.cancelAria')}
>
<Icon name="close" className="size-4" />
</Button>
)}
/>
);
}
return null;
};
@@ -161,21 +184,18 @@ const useBtwSessionData = (
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
/** Composer and popup handlers get first refusal; the owner decides cancel versus collapse. */
const useEscapeToExit = (onExit: () => void, enabled: boolean): void => {
React.useEffect(() => {
if (!enabled) return;
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();
if (event.key !== 'Escape' || event.defaultPrevented || isIMECompositionEvent(event)) return;
event.preventDefault();
onExit();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
}, [enabled, onExit]);
};
/**
@@ -217,14 +237,14 @@ const useAutoScroll = (
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
draftHint?: string;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
}> = ({ actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, draftHint, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
@@ -245,17 +265,12 @@ const BtwFrame: React.FC<{
) : (
<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>
{draftHint ? <span className="typography-ui-label truncate">{draftHint}</span> : null}
</span>
)}
<div className="min-w-0 flex-1" />
@@ -273,23 +288,20 @@ const BtwFrame: React.FC<{
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
}> = ({ sessionRef, 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';
@@ -324,7 +336,6 @@ const BtwSheet: React.FC<{
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
@@ -335,7 +346,6 @@ const BtwSheet: React.FC<{
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
@@ -351,16 +361,14 @@ const BtwSheet: React.FC<{
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
}> = ({ sessionRef, 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}
@@ -372,12 +380,11 @@ const BtwCollapsedStrip: React.FC<{
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
}> = ({ sessionRef, 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);
@@ -395,7 +402,7 @@ const BtwExpandedSheet: React.FC<{
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<BtwFrame actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
@@ -16,6 +16,7 @@ export type BtwPanelState = {
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
pending: boolean;
};
/**
@@ -53,5 +54,6 @@ export function useBtwPanelState(
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
pending: Boolean(uiState?.pending),
};
}
@@ -0,0 +1,118 @@
import React, { act, StrictMode, Suspense } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { LiveActivityCollapse } from './LiveActivityCollapse';
describe('live Activity collapse layout lifecycle', () => {
let root: Root;
let container: HTMLDivElement;
let restore: () => void;
beforeEach(() => {
const win = new Window({ url: 'http://localhost' });
const globals = {
window: win, document: win.document, HTMLElement: win.HTMLElement,
Element: win.Element, SVGElement: win.SVGElement, NodeList: win.NodeList,
requestAnimationFrame: win.requestAnimationFrame.bind(win),
cancelAnimationFrame: win.cancelAnimationFrame.bind(win),
getComputedStyle: win.getComputedStyle.bind(win),
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
for (const [name, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
};
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
restore();
});
test('settles the target height when React cleans up and replays a collapse layout effect', async () => {
await act(async () => root.render(
<StrictMode>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
// Happy DOM has no layout engine. Supply the height
// measured before a real historical turn collapses.
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
</StrictMode>,
));
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('settles a collapse after a Suspense hide/reveal in production lifecycle', async () => {
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={false} animateOnMount>
<div ref={(node) => {
if (!node?.parentElement) return;
Object.defineProperty(node.parentElement, 'scrollHeight', { configurable: true, get: () => 2400 });
}}>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('0px');
expect(region?.childElementCount).toBe(0);
});
test('restores natural height when an expansion is interrupted by Suspense', async () => {
let expanded = false;
let suspended = false;
let release: () => void = () => undefined;
const pending = new Promise<void>((resolve) => { release = resolve; });
function LoadingSibling() {
if (suspended) throw pending;
return null;
}
const render = () => (
<Suspense fallback={<div>Loading history</div>}>
<LiveActivityCollapse expanded={expanded}>
<div>Historical activity</div>
</LiveActivityCollapse>
<LoadingSibling />
</Suspense>
);
await act(async () => root.render(render()));
expanded = true;
await act(async () => root.render(render()));
suspended = true;
await act(async () => root.render(render()));
suspended = false;
await act(async () => { release(); });
const region = container.querySelector<HTMLElement>('[data-live-activity-content]');
expect(region?.style.height).toBe('auto');
expect(region?.style.overflow).toBe('visible');
expect(region?.textContent).toBe('Historical activity');
});
});
@@ -0,0 +1,72 @@
import React from 'react';
import { animate } from 'motion';
interface LiveActivityCollapseProps {
expanded: boolean;
children: React.ReactNode;
id?: string;
animateOnMount?: boolean;
}
export function LiveActivityCollapse({ expanded, children, id, animateOnMount = false }: LiveActivityCollapseProps) {
const ref = React.useRef<HTMLDivElement>(null);
const previousExpanded = React.useRef(expanded || animateOnMount);
const [retained, setRetained] = React.useState(expanded || animateOnMount);
const mounted = expanded || retained;
React.useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const settle = () => {
element.style.height = expanded ? 'auto' : '0px';
element.style.overflow = expanded ? 'visible' : 'hidden';
setRetained(expanded);
};
// Suspense can clean up a layout effect while retaining its DOM, then
// replay setup on reveal. The old animation was stopped, but the ref
// still records its target. Skipping setup here would freeze the
// measured pre-collapse height and retain an empty historical region.
if (previousExpanded.current === expanded) {
settle();
return;
}
previousExpanded.current = expanded;
if (expanded) setRetained(true);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
settle();
return;
}
element.style.height = expanded ? '0px' : `${element.scrollHeight}px`;
element.style.overflow = 'hidden';
const animation = animate(element, { height: expanded ? 'auto' : '0px' }, {
duration: 0.18,
ease: [0.16, 1, 0.3, 1],
});
let cancelled = false;
const finish = () => {
if (!cancelled) settle();
};
void animation.finished.then(finish, finish);
return () => {
cancelled = true;
animation.stop();
};
}, [expanded]);
return (
<div
ref={ref}
id={id}
aria-hidden={!expanded}
inert={!expanded}
data-live-activity-content="true"
style={{
height: mounted ? 'auto' : 0,
overflow: mounted ? 'visible' : 'hidden',
overflowAnchor: 'none',
}}
>
{mounted ? children : null}
</div>
);
}
@@ -0,0 +1,201 @@
import React, { act } from 'react';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { plugin } from 'bun';
import { pathToFileURL } from 'node:url';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { createOpencodeClient, type Part, type AssistantMessage } from '@opencode-ai/sdk/v2';
import { I18nProvider } from '@/lib/i18n';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { SyncProvider } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types';
import { LiveTurnActivity } from './LiveTurnActivity';
plugin({
name: 'live-activity-worker-url',
setup(build) {
build.onLoad({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, ({ path }) => ({
contents: `export default ${JSON.stringify(pathToFileURL(path.split('?')[0]).href)};`, loader: 'js',
}));
// Expand Vite's eager asset glob into the same real file URL map for
// Bun. This is a loader transform, not a replacement of the logo hook.
build.onLoad({ filter: /useProviderLogo\.ts$/ }, ({ path }) => {
const folder = resolve(dirname(path), '../assets/provider-logos');
const logos = Object.fromEntries(readdirSync(folder).filter((name) => name.endsWith('.svg'))
.map((name) => [`../assets/provider-logos/${name}`, pathToFileURL(resolve(folder, name)).href]));
const contents = readFileSync(path, 'utf8').replace(/import\.meta\.glob<string>\([\s\S]*?\);/, `${JSON.stringify(logos)};`);
return { contents, loader: 'ts' };
});
},
});
const unavailable = (): never => { throw new Error('Activity rendering must not call runtime APIs'); };
const runtimeApis: RuntimeAPIs = {
runtime: { platform: 'web', isDesktop: false, isVSCode: false },
get terminal() { return unavailable(); },
get git() { return unavailable(); },
get files() { return unavailable(); },
get settings() { return unavailable(); },
get permissions() { return unavailable(); },
get notifications() { return unavailable(); },
get tools() { return unavailable(); },
};
const sdk = createOpencodeClient({ baseUrl: 'http://localhost', fetch: async () => new Response('[]', { headers: { 'Content-Type': 'application/json' } }) });
let MessageBody: typeof import('../message/MessageBody').default;
function assistant(id: string, parts: Part[], finish?: string): ChatMessageEntry {
const info: AssistantMessage = {
id, sessionID: 'session', role: 'assistant', parentID: 'user', time: { created: 2, completed: finish ? 3 : undefined },
modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build', path: { cwd: '/project', root: '/project' },
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish,
};
return { info, parts };
}
function text(id: string, content: string): Part {
return { type: 'text', id, text: content, sessionID: 'session', messageID: 'message' };
}
const readPart: Part = {
type: 'tool', tool: 'read', id: 'read', callID: 'read', sessionID: 'session', messageID: 'progress',
state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: 'code', title: 'Read', metadata: {}, time: { start: 1, end: 2 } },
};
function turn(messages: ChatMessageEntry[]): TurnRecord {
return projectTurnRecords([{
info: { id: 'user', sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } },
parts: [text('request', 'Request')],
}, ...messages]).turns[0];
}
function Harness({ record, retired = false }: { record: TurnRecord; retired?: boolean }) {
const [expanded, setExpanded] = React.useState(false);
const renderMessage = (message: ChatMessageEntry) => (
<div key={message.info.id} data-fixture-message={message.info.id}>
<MessageBody
messageId={message.info.id} parts={message.parts} isUser={false}
isMessageCompleted={message.info.role === 'assistant' && Boolean(message.info.finish)}
messageFinish={message.info.role === 'assistant' ? message.info.finish : undefined}
isMobile={false} copiedCode={null} onCopyCode={() => undefined} expandedTools={new Set()}
onToggleTool={() => undefined} onShowPopup={() => undefined} streamPhase="completed" allowAnimation={false}
hasTextContent={message.parts.some((part) => part.type === 'text')} showReasoningTraces
turnGroupingContext={{
turnId: 'user', isFirstAssistantInTurn: message === record.assistantMessages[0],
isLastAssistantInTurn: message === record.assistantMessages.at(-1),
isLatestTurn: true, isWorking: false, hasTools: record.hasTools, hasReasoning: record.hasReasoning,
}}
/>
</div>
);
return <RuntimeAPIContext.Provider value={runtimeApis}>
<SyncProvider sdk={sdk} directory="/project">
<I18nProvider>
<LiveTurnActivity turn={record} hasLaterAssistant={retired} expanded={expanded}
onToggle={() => setExpanded((value) => !value)} renderMessage={renderMessage} />
</I18nProvider>
</SyncProvider>
</RuntimeAPIContext.Provider>;
}
describe('live Activity with the real message body', () => {
let root: Root;
let container: HTMLDivElement;
let restore: () => void;
beforeEach(async () => {
const win = new Window({ url: 'http://localhost', settings: { device: { prefersReducedMotion: 'reduce' } } });
const globals = {
window: win, document: win.document, navigator: win.navigator, localStorage: win.localStorage,
customElements: win.customElements,
Node: win.Node, NodeList: win.NodeList, Element: win.Element, HTMLElement: win.HTMLElement, SVGElement: win.SVGElement,
HTMLAnchorElement: win.HTMLAnchorElement,
MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver,
requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win),
getComputedStyle: win.getComputedStyle.bind(win), IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
for (const [name, value] of Object.entries(globals)) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
// Reduced motion makes the disclosure lifecycle deterministic without
// replacing the real component or animation module.
restore = () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
};
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
useUIStore.setState({ chatRenderMode: 'live', collapsibleThinkingBlocks: false, showSplitAssistantMessageActions: false });
useDirectoryStore.setState({ currentDirectory: '/project' });
MessageBody = (await import('../message/MessageBody')).default;
});
afterEach(async () => {
await act(async () => root.unmount());
restore();
});
test('keeps active prose and tools visible; stop folds history but leaves the answer', async () => {
const progress = assistant('progress', [text('progress-text', 'Checking the source'), readPart], 'tool-calls');
await act(async () => root.render(<Harness record={turn([progress])} />));
expect(container.textContent).toContain('Checking the source');
expect(container.querySelector('[aria-controls]')).toBeNull();
expect(container.textContent).not.toContain('Activity');
const final = assistant('final', [text('final-text', 'The final answer')], 'stop');
await act(async () => root.render(<Harness record={turn([progress, final])} />));
expect(container.textContent).toContain('The final answer');
expect(container.textContent).not.toContain('Checking the source');
const header = container.querySelector<HTMLButtonElement>('button[aria-controls]');
expect(header?.getAttribute('aria-expanded')).toBe('false');
expect(header?.textContent).toContain('Explored codebase');
await act(async () => header?.click());
expect(header?.textContent).toContain('Explored codebase');
expect(container.textContent).toContain('Checking the source');
expect(container.textContent).toContain('The final answer');
await act(async () => root.render(<Harness record={turn([progress, { ...final, parts: [...final.parts] }])} />));
expect(header?.getAttribute('aria-expanded')).toBe('true');
});
test('keeps thinking in the final message inside Activity, not outside with the answer', async () => {
const thinking: Part = { type: 'reasoning', id: 'thinking', messageID: 'final', sessionID: 'session', text: 'Private reasoning content', time: { start: 1, end: 2 } };
const final = assistant('final', [thinking, text('final-text', 'Public answer')], 'stop');
await act(async () => root.render(<Harness record={turn([final])} />));
expect(container.textContent).toContain('Public answer');
expect(container.textContent).not.toContain('Private reasoning content');
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-controls]')?.click());
expect(container.textContent).toContain('Private reasoning content');
expect(container.textContent).toContain('Public answer');
});
test('an interrupted turn folds all prose without fabricating a final answer', async () => {
const record = turn([assistant('progress', [text('progress-text', 'Still working'), readPart], 'tool-calls')]);
await act(async () => root.render(<Harness record={record} />));
expect(container.textContent).toContain('Still working');
expect(container.textContent).not.toContain('Activity');
await act(async () => root.render(<Harness record={record} retired />));
expect(container.textContent).not.toContain('Still working');
expect(container.textContent).toContain('Activity');
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-controls]')?.click());
expect(container.textContent).toContain('Still working');
});
test('keeps file statistics visible when expanded and uses an ASCII minus', async () => {
const edit: Part = {
type: 'tool', tool: 'edit', id: 'edit', callID: 'edit', sessionID: 'session', messageID: 'progress',
state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: '', title: 'Edit',
metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+new\n+added' }, time: { start: 1, end: 2 } },
};
await act(async () => root.render(<Harness record={turn([
assistant('progress', [edit], 'tool-calls'),
assistant('final', [text('answer', 'Done')], 'stop'),
])} />));
const header = container.querySelector<HTMLButtonElement>('button[aria-controls]');
expect(header?.textContent).toContain('Changed 1 file');
expect(header?.textContent).toContain('+2/-1');
await act(async () => header?.click());
expect(header?.textContent).toContain('Changed 1 file');
expect(header?.textContent).toContain('+2/-1');
});
});
@@ -0,0 +1,86 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types';
import { getLiveFinalMessage } from '../lib/turns/liveActivity';
import { summarizeLiveActivity } from '../lib/turns/liveActivitySummary';
import { LiveActivityCollapse } from './LiveActivityCollapse';
import { LiveFinalActivityContext } from './liveActivityContext';
interface LiveTurnActivityProps {
turn: TurnRecord;
hasLaterAssistant: boolean;
expanded: boolean;
onToggle: () => void;
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
}
export function LiveTurnActivity({ turn, hasLaterAssistant, expanded, onToggle, renderMessage }: LiveTurnActivityProps) {
const { t } = useI18n();
const contentId = React.useId();
const finalContentId = React.useId();
const finalMessage = getLiveFinalMessage(turn.assistantMessages);
const settled = Boolean(finalMessage) || hasLaterAssistant;
const isExpanded = !settled || expanded;
const previouslySettled = React.useRef(settled);
const animateFinalCollapse = settled && !previouslySettled.current;
React.useLayoutEffect(() => { previouslySettled.current = settled; }, [settled]);
const finalContext = React.useMemo(() => finalMessage ? {
messageId: finalMessage.info.id, expanded: isExpanded, contentId: finalContentId, animateCollapse: animateFinalCollapse,
} : null, [finalMessage, isExpanded, finalContentId, animateFinalCollapse]);
// No diff parsing at token frequency. The report is only shown once the
// turn settles; later authoritative tool metadata can refine it.
const summary = React.useMemo(() => settled ? summarizeLiveActivity(turn.assistantMessages) : null,
[settled, turn.assistantMessages]);
const fileLabel = summary && summary.files > 0
? t(summary.files === 1 ? 'chat.liveActivity.changedFile' : 'chat.liveActivity.changedFiles', { count: summary.files })
: null;
const details = summary ? [
summary.explored ? t('chat.liveActivity.explored') : null,
summary.commands > 0 ? t(summary.commands === 1 ? 'chat.liveActivity.ranCommand' : 'chat.liveActivity.ranCommands', { count: summary.commands }) : null,
summary.researched ? t('chat.liveActivity.researched') : null,
summary.subagents > 0 ? t(summary.subagents === 1 ? 'chat.liveActivity.usedSubagent' : 'chat.liveActivity.usedSubagents', { count: summary.subagents }) : null,
].filter(Boolean).join(' · ') : '';
const label = (
<>
<Icon name="stack" className="size-3.5 shrink-0 text-[var(--tools-icon)]" />
<span className="shrink-0 font-semibold text-[var(--tools-title)]">{t('chat.liveActivity.title')}</span>
{settled ? <Icon name={isExpanded ? 'arrow-down-s' : 'arrow-right-s'} className="size-3 shrink-0" /> : null}
{fileLabel ? (
<span className="flex min-w-0 items-center gap-1 typography-meta @min-[560px]:shrink-0">
<span className="truncate">{fileLabel}</span>
{summary?.hasCompleteDiff && (summary.additions > 0 || summary.deletions > 0) ? (
<span className="shrink-0 tabular-nums">
<span className="text-[var(--status-success)]">+{summary.additions}</span>
<span aria-hidden="true">/</span>
<span className="text-[var(--status-error)]">-{summary.deletions}</span>
</span>
) : null}
</span>
) : null}
{details ? <span className="hidden min-w-0 flex-1 truncate text-left typography-meta @min-[560px]:inline" title={details}>{fileLabel ? '· ' : ''}{details}</span> : null}
</>
);
const headerClass = 'w-full justify-start normal-case !pl-px !pr-2 text-[var(--tools-description)] hover:!bg-transparent active:!bg-transparent';
return (
<div className="relative z-0" data-live-turn-activity={turn.turnId}>
{settled ? (
<div className="chat-message-column @container">
<div className="mt-1">
<Button variant="ghost" size="sm" className={headerClass} onClick={onToggle}
aria-expanded={isExpanded} aria-controls={finalMessage ? `${contentId} ${finalContentId}` : contentId}>
{label}
</Button>
</div>
</div>
) : null}
<LiveActivityCollapse expanded={isExpanded} id={contentId}>
{turn.assistantMessages.map((message) => message === finalMessage ? null : renderMessage(message))}
</LiveActivityCollapse>
<LiveFinalActivityContext.Provider value={finalContext}>
{finalMessage ? renderMessage(finalMessage) : null}
</LiveFinalActivityContext.Provider>
</div>
);
}
@@ -7,6 +7,7 @@ interface TurnItemProps {
turn: Turn;
stickyUserHeader?: boolean;
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
assistantContent?: React.ReactNode;
}
/**
@@ -23,7 +24,7 @@ const STICKY_HEADER_BACKGROUND: React.CSSProperties = {
'linear-gradient(to bottom, var(--surface-background) calc(100% - 0.75rem), transparent)',
};
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage, assistantContent }) => {
return (
<section
className="relative w-full"
@@ -44,7 +45,7 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
renderMessage(turn.userMessage)
)}
<TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />
{assistantContent ?? <TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />}
</section>
);
};
@@ -0,0 +1,9 @@
import { createContext } from 'react';
/** Only the final message splits its non-text parts from its answer/footer. */
export const LiveFinalActivityContext = createContext<{
messageId: string;
expanded: boolean;
contentId: string;
animateCollapse: boolean;
} | null>(null);
@@ -171,7 +171,7 @@ and the send path reading the same grammar.
attached context is consumed. Commands that act on session or UI state
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
their command text and leave comments, files, and linked context attached;
commands that produce a prompt (`/btw` and the magic prompts) send that
magic prompt commands send that
context with the prompt they produce. Session actions are planned only when
a session exists, so typing one into a new-session draft stays on the normal
send path. A local command is never queued as text: queueing runs it
@@ -183,14 +183,30 @@ and the send path reading the same grammar.
draft. Two orderings are load-bearing: the debounced write is skipped once
while a draft is being restored, and a deleted draft's empty signature is
recorded before a queued write could resurrect it.
Fork replay text and files arrive in `input-store.pendingComposerRestore`,
addressed to the fork's runtime, directory, and session. The hook consumes
them after loading that identity's draft. Selection alone is not enough:
the deferred chat column can still show the source composer. Ordinary
pending text insertions keep their existing path in `ChatInput`.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation. It
also owns the advisory dirty state for the selected directory, clearing it as
soon as the target changes so a warning never names a previous branch.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
state and registers its application shortcuts locally. The desktop project
picker is a searchable popup: it ranks the current projects with
`rankByQuery` over display label and path, keeps the query and the active
result as transient local state that resets on every close, and commits
through the existing project-change flow only on explicit activation.
Filtering changes the result area below the anchored input without moving
the search field. The worktree picker remains a Select; mobile keeps its
bottom sheets. The selectors only consume their shared prefix while the
draft target UI is mounted.
Keyboard selection returns focus to the current form's composer, including
when the selected value is unchanged.
- `ChatInput.tsx` maps Ctrl+N/P to the active command, skill, snippet, or
mention picker after its IME guard.
## Input recall ownership
@@ -216,6 +232,34 @@ session bucket, which adds attachments and keeps prompts a revert hid from the
timeline. A prompt present in both collapses to the persisted entry. Global
scope reads the persisted runtime bucket only.
## BTW composer
An empty `/btw` opens an unsent draft. `/btw <question>` opens BTW and sends
that question immediately after its own draft and model selection are active.
**By the way…** opens an unsent draft with Quote-formatted selection text.
The first send creates the fork; Enter follows the user's preference. Pending text and references then
move to the fork's draft identity. Normal and BTW drafts remain independent,
including in memory when persistence is disabled.
Both modes reuse `ComposerEditor` and `ModelControls`; BTW transitions put the
caret at the end. BTW copies the main model/effort once, including explicit
Default, and uses `plan` or the first selectable agent. Its controlled model
path only writes BTW selections. Attachments, goals, expansion, shell, and
agent selection and file/agent mention autocomplete are unavailable. Auto-accept is applied before the first send.
On mobile, model and effort controls sit in the input's upper-left row; the
footer only contains auto-accept and send/stop controls.
Escape closes menus first. Otherwise it returns to normal: an unsent BTW is
discarded with its text, references, selections and panel; a creating or real
fork is only collapsed. Neither exit sends, aborts, or deletes a server session,
nor consumes the main draft's files, queue, or linked context. Pending snippet
expansion belongs to the unsent panel. Discarding that panel invalidates the
send, and a runtime change prevents fork creation and stale UI recovery.
The unsent panel shows "Ask your question" until fork creation starts.
Existing panels hide titles. Promotion retains the existing internal title, without
transcript fetching or Small Model generation.
## Mobile
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
@@ -247,10 +291,13 @@ suites that install module mocks are order-dependent.
## Enter preference
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter,
and Shift-modified Enter does not send. An explicit choice applies across
shared composers; Ctrl/Cmd+Enter sends in either configured mode.
`keyboardPolicy.ts` owns the submission decision. The expanded desktop composer
always inserts a newline with Enter, including Shift+Enter, and sends with
Ctrl/Cmd+Enter; it ignores the Enter-to-send preference. Outside expanded mode,
until the Chat setting is changed, desktop Enter sends, mobile requires
Ctrl/Cmd+Enter, and Shift-modified Enter does not send. An explicit choice
applies across the other shared composers; Ctrl/Cmd+Enter sends in either
configured mode.
CodeMirror's deferred mobile Enter loses modifier information. Untouched
settings restore Shift to keep the original policy. Once configured, with mobile
@@ -26,6 +26,10 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
['configured enabled Shift+Enter inserts a newline', { enterToSendConfigured: true, enterToSend: true, shiftKey: true }, false],
['configured disabled Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false],
['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true],
['expanded composer Enter inserts a newline when Enter-to-send is enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true }, false],
['expanded composer Shift+Enter inserts a newline when Enter-to-send is disabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: false, shiftKey: true }, false],
['expanded composer Ctrl+Enter sends despite Enter-to-send being disabled', { isDesktopExpanded: true, enterToSendConfigured: true, ctrlKey: true }, true],
['expanded composer Cmd+Enter sends despite Enter-to-send being enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true, metaKey: true }, true],
['configured Ctrl+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, ctrlKey: true }, true],
['configured Meta+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, metaKey: true }, true],
];
@@ -33,8 +37,9 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
describe('Enter key policy', () => {
for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) {
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) {
test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false);
test(`untouched Shift+Enter only submits with a modifier in expanded mode: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true })))
.toBe(Boolean(surface.isDesktopExpanded && (modifiers.ctrlKey || modifiers.metaKey)));
});
}
}
@@ -9,8 +9,10 @@ export interface EnterKeyPolicyInput {
}
export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
const isCtrlEnter = input.ctrlKey || input.metaKey;
if (input.isDesktopExpanded) return isCtrlEnter;
const enterSendsByDefault = !input.isMobile;
if (!input.enterToSendConfigured) {
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
}
@@ -125,3 +125,12 @@ describe('precedence and disabling', () => {
expect(at('|')).toBeNull();
});
});
test('BTW leaves file and agent references as text while retaining other pickers', () => {
const btw: TriggerContext = { inputMode: 'normal', mentionsEnabled: false };
expect(at('@src/file|', btw)).toBeNull();
expect(at('@plan|', btw)).toBeNull();
expect(at('#snippet|', btw)).toEqual({ kind: 'snippet', query: 'snippet' });
expect(at('@plan|')?.kind).toBe('mention');
});
@@ -30,6 +30,7 @@ export interface AutocompleteTrigger {
export interface TriggerContext {
/** Shell mode (`!cmd`) disables every picker. */
inputMode: 'normal' | 'shell';
mentionsEnabled?: boolean;
/** Whether the change that moved the caret came from a paste. */
inputSource?: FileMentionAutocompleteInputSource;
/** The text that change inserted, when known. */
@@ -106,6 +107,7 @@ function matchMention(
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
if (context.mentionsEnabled === false) return null;
const query = getFileMentionAutocompleteQuery({
value,
cursorPosition,
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { installHookTestDom } from '@/components/session/sidebar/test-utils/testDom';
import { readChatDraft, writeChatDraft, type ChatDraftIdentity } from '@/lib/chatDraftPersistence';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useInputStore } from '@/sync/input-store';
import { useComposerDraft } from '../useComposerDraft';
const source: ChatDraftIdentity = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'source' };
const fork: ChatDraftIdentity = { ...source, sessionId: 'fork' };
const replayFile = { url: 'data:text/plain;base64,aGVsbG8=', mimeType: 'text/plain', filename: 'replay.txt' };
function renderComposer(persistEnabled: boolean) {
const dom = installHookTestDom();
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
globalThis.requestAnimationFrame = (callback) => {
frames.set(++frameId, callback);
return frameId;
};
globalThis.cancelAnimationFrame = (id) => { frames.delete(id); };
const root = createRoot(dom.container);
const restored: string[] = [];
const result = { text: '', mentions: new Set<string>(), restored };
function Probe({ identity }: { identity: ChatDraftIdentity }) {
const [message, setMessage] = React.useState('source draft @source.ts');
const messageRef = React.useRef(message);
const confirmedMentionsRef = React.useRef(new Set(['source.ts']));
React.useEffect(() => { messageRef.current = message; }, [message]);
useComposerDraft({
message, messageRef, setMessage, confirmedMentionsRef, identity, persistEnabled,
initialDraft: { text: '', identity: source },
onDraftRestored: (reason) => { result.restored.push(reason); },
});
result.text = message;
result.mentions = confirmedMentionsRef.current;
return null;
}
const render = (identity: ChatDraftIdentity) => {
act(() => { root.render(React.createElement(Probe, { identity })); });
};
render(source);
return {
result,
render,
flushFrames: () => {
const pending = [...frames.values()];
frames.clear();
act(() => { for (const callback of pending) callback(0); });
},
teardown: () => {
act(() => { root.unmount(); });
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
dom.restore();
},
};
}
beforeEach(() => {
getDeferredSafeStorage().removeItem('openchamber.chatDrafts.v2');
useInputStore.setState({ pendingComposerRestore: null });
useInputStore.getState().clearAttachedFiles();
});
describe('fork composer restoration', () => {
for (const persistEnabled of [true, false]) {
test(`waits for the rendered fork and preserves the source, persistence=${persistEnabled}`, () => {
writeChatDraft(fork, 'previous fork draft @old.ts', ['old.ts']);
useInputStore.getState().addRestoredAttachment({ ...replayFile, filename: 'source.txt' });
const sourceFiles = useInputStore.getState().attachedFiles;
const composer = renderComposer(persistEnabled);
try {
// Selection already changed, but the deferred chat column still renders source.
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay prompt', files: [replayFile] } });
});
expect(composer.result.text).toBe('source draft @source.ts');
expect(useInputStore.getState().attachedFiles).toBe(sourceFiles);
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
composer.render(fork);
expect(composer.result.text).toBe('replay prompt');
expect(composer.result.mentions.size).toBe(0);
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
composer.flushFrames();
expect(composer.result.restored).toContain('fork');
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
composer.render(source);
expect(composer.result.text).toBe('source draft @source.ts');
expect(readChatDraft(fork).text).toBe(persistEnabled ? 'replay prompt' : '');
} finally {
composer.teardown();
}
});
}
test('waits through unrelated session, directory, and runtime renders', () => {
const composer = renderComposer(true);
try {
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: 'replay', files: [] } });
});
for (const identity of [
{ ...fork, sessionId: 'other' },
{ ...fork, directory: '/other' },
{ ...fork, runtimeKey: 'runtime-b' },
]) {
composer.render(identity);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().pendingComposerRestore).not.toBeNull();
}
composer.render(fork);
expect(composer.result.text).toBe('replay');
expect(useInputStore.getState().pendingComposerRestore).toBeNull();
} finally {
composer.teardown();
}
});
test('persists a replay even when its text equals the outgoing source draft', async () => {
const composer = renderComposer(true);
try {
const text = composer.result.text;
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text, files: [] } });
});
composer.render(fork);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 550)); });
expect(readChatDraft(fork)).toEqual({ text, confirmedMentions: new Set() });
expect(readChatDraft(source)).toEqual({ text, confirmedMentions: new Set(['source.ts']) });
} finally {
composer.teardown();
}
});
test('restores file-only and empty prompts without keeping destination text or files', () => {
const composer = renderComposer(true);
try {
writeChatDraft(fork, 'stale destination text', []);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [replayFile] } });
});
composer.render(fork);
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles.map((file) => file.filename)).toEqual(['replay.txt']);
act(() => {
useInputStore.setState({ pendingComposerRestore: { target: fork, text: '', files: [] } });
});
expect(composer.result.text).toBe('');
expect(useInputStore.getState().attachedFiles).toEqual([]);
} finally {
composer.teardown();
}
});
});
@@ -12,13 +12,16 @@
*/
import React from 'react';
import { useInputStore } from '@/sync/input-store';
import {
clearChatDraft,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
const PERSIST_DEBOUNCE_MS = 500;
@@ -46,14 +49,14 @@ export interface ComposerDraftOptions {
confirmedMentionsRef: React.RefObject<Set<string>>;
/** The draft this composer currently belongs to. */
identity: ChatDraftIdentity | null;
/** User setting: when off, drafts are discarded rather than stored. */
/** User setting: when off, drafts stay in memory without durable writes. */
persistEnabled: boolean;
/** The draft restored on mount, if any. */
initialDraft: { text: string; identity: ChatDraftIdentity | null };
/** Called when the composer switches to a different draft identity. */
onIdentityChange?: () => void;
/** Called after a non-empty draft is restored, to select its text. */
onDraftRestored?: () => void;
/** Called after restoring a saved draft or fork replay, to select its text. */
onDraftRestored?: (source: 'saved' | 'fork') => void;
}
export interface ComposerDraftControls {
@@ -62,6 +65,12 @@ export interface ComposerDraftControls {
* cleared composer must be stored before the send resolves.
*/
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
/** Consume a command in the current draft while opening another draft. */
handoffDraft: (identity: ChatDraftIdentity | null, draft: string | null) => void;
/** Restore a draft after a failed send without using persistence as state. */
restoreDraft: (identity: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => void;
/** Move an in-memory draft to an identity materialized during an async flow. */
migrateDraft: (from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => void;
}
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
@@ -81,6 +90,16 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
const skipNextPersistRef = React.useRef(false);
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
const draftMemoryRef = React.useRef(new Map<string, ChatDraftSnapshot>());
const skipOutgoingDraftRef = React.useRef(false);
const initialKey = initialDraft.identity ? getChatDraftIdentityKey(initialDraft.identity) : null;
if (persistEnabled && initialKey && !draftMemoryRef.current.has(initialKey) && initialDraft.text) {
draftMemoryRef.current.set(initialKey, {
text: initialDraft.text,
confirmedMentions: new Set(confirmedMentionsRef.current),
});
}
const pendingComposerRestore = useInputStore((state) => state.pendingComposerRestore);
// Callbacks reach the effects through a ref so a caller passing inline
// functions does not re-run the persistence effects on every render.
@@ -91,8 +110,13 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
currentIdentityRef.current = identity;
}, [identity]);
React.useEffect(() => {
// Persistence off keeps in-memory drafts, but must not retain old disk copies.
if (!persistEnabled && identity) clearChatDraft(identity);
}, [identity, persistEnabled]);
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
if (!target) return;
if (!persistEnabled || !target) return;
const key = getChatDraftIdentityKey(target);
// Only keep confirmed mentions the draft still contains: a mention the
@@ -108,7 +132,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
writeChatDraft(target, draft, activeMentions);
lastPersistedRef.current.set(key, signature);
}, [confirmedMentionsRef]);
}, [confirmedMentionsRef, persistEnabled]);
const clearPending = React.useCallback(() => {
if (!persistTimerRef.current) return;
@@ -125,11 +149,12 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
if (!initialDraft.text) return;
if (!persistEnabled) {
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
writeChatDraft(initialDraft.identity, '', []);
return;
}
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
// Runs once; the initial draft is captured at mount by design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistEnabled]);
@@ -149,27 +174,55 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// debounced effect must not immediately write it back out.
skipNextPersistRef.current = true;
if (!persistEnabled) {
setMessage('');
confirmedMentionsRef.current = new Set();
return;
if (!skipOutgoingDraftRef.current && previousKey) {
const outgoing = { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) };
draftMemoryRef.current.set(previousKey, outgoing);
if (persistEnabled) persistNow(previous, outgoing.text);
}
skipOutgoingDraftRef.current = false;
persistNow(previous, messageRef.current);
const restored = readChatDraft(identity);
const restored = (currentKey && draftMemoryRef.current.get(currentKey))
|| (persistEnabled ? readChatDraft(identity) : { text: '', confirmedMentions: new Set<string>() });
messageRef.current = restored.text;
setMessage(restored.text);
confirmedMentionsRef.current = restored.confirmedMentions;
confirmedMentionsRef.current = new Set(restored.confirmedMentions);
if (restored.text) {
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
}
}, [clearPending, confirmedMentionsRef, identity, messageRef, persistEnabled, persistNow, setMessage]);
// The chat column can still show the source after navigation selects a fork.
// Apply its replay only after the destination's draft has been loaded above.
React.useEffect(() => {
if (!pendingComposerRestore) return;
const input = useInputStore.getState();
const pending = input.consumePendingComposerRestore(identity);
if (!pending) return;
clearPending();
skipNextPersistRef.current = true;
messageRef.current = pending.text;
confirmedMentionsRef.current = new Set();
setMessage(pending.text);
// Equal source/replay text need not trigger another render to persist.
if (persistEnabled) persistNow(pending.target, pending.text);
input.clearAttachedFiles();
for (const file of pending.files) input.addRestoredAttachment(file);
requestAnimationFrame(() => {
const current = currentIdentityRef.current;
if (current && getChatDraftIdentityKey(current) === getChatDraftIdentityKey(pending.target)) {
callbacksRef.current.onDraftRestored?.('fork');
}
});
}, [clearPending, confirmedMentionsRef, identity, messageRef, pendingComposerRestore, persistEnabled, persistNow, setMessage]);
// A draft deleted elsewhere (session deleted, drafts cleared) clears the
// composer if it is the one on screen.
React.useEffect(() => subscribeChatDraftDeletion((deleted) => {
const deletedKey = getChatDraftIdentityKey(deleted);
// Record the empty signature so a queued write does not resurrect it.
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
draftMemoryRef.current.set(deletedKey, { text: '', confirmedMentions: new Set() });
const current = currentIdentityRef.current;
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
@@ -183,11 +236,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// Debounced write while typing.
React.useEffect(() => {
if (!persistEnabled) {
clearPending();
persistNow(identity, '');
return;
}
if (!persistEnabled) return;
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
@@ -226,5 +275,62 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
};
}, [clearPending, messageRef, persistEnabled, persistNow]);
return { persistNow };
const restoreDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
const current = currentIdentityRef.current;
const isCurrent = target && current && getChatDraftIdentityKey(target) === getChatDraftIdentityKey(current);
const existing = isCurrent
? { text: messageRef.current, confirmedMentions: confirmedMentionsRef.current }
: (targetKey && draftMemoryRef.current.get(targetKey)) || (persistEnabled ? readChatDraft(target) : null);
const text = existing?.text && existing.text !== draft ? `${existing.text}\n\n${draft}` : draft;
const mentions = new Set([...(existing?.confirmedMentions ?? []), ...confirmedMentions]);
if (targetKey) draftMemoryRef.current.set(targetKey, { text, confirmedMentions: mentions });
if (isCurrent) {
messageRef.current = text;
confirmedMentionsRef.current = new Set(mentions);
setMessage(text);
}
if (persistEnabled && target) {
writeChatDraft(target, text, mentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(target), draftSignature(text, mentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled, setMessage]);
const handoffDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string | null) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
if (targetKey && draft !== null) draftMemoryRef.current.set(targetKey, { text: draft, confirmedMentions: new Set() });
const currentKey = currentIdentityRef.current ? getChatDraftIdentityKey(currentIdentityRef.current) : null;
if (targetKey === currentKey) {
if (draft !== null) {
messageRef.current = draft;
confirmedMentionsRef.current = new Set();
setMessage(draft);
persistNow(target, draft);
}
return;
}
if (currentKey) draftMemoryRef.current.set(currentKey, { text: '', confirmedMentions: new Set() });
persistNow(currentIdentityRef.current, '');
skipOutgoingDraftRef.current = true;
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
}, [confirmedMentionsRef, messageRef, persistNow, setMessage]);
const migrateDraft = React.useCallback((from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => {
if (!to) return;
const current = currentIdentityRef.current;
const draft = from && current && getChatDraftIdentityKey(from) === getChatDraftIdentityKey(current)
? { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) }
: (from && draftMemoryRef.current.get(getChatDraftIdentityKey(from)))
|| (persistEnabled ? readChatDraft(from) : null);
if (!draft) return;
draftMemoryRef.current.set(getChatDraftIdentityKey(to), { text: draft.text, confirmedMentions: new Set(draft.confirmedMentions) });
if (persistEnabled) {
writeChatDraft(to, draft.text, draft.confirmedMentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(to), draftSignature(draft.text, draft.confirmedMentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled]);
return { persistNow, handoffDraft, restoreDraft, migrateDraft };
}
@@ -24,6 +24,7 @@ import { ComposerActionButtons } from './ComposerActionButtons';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
import { FocusModeButton } from './FocusModeButton';
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
import type { BtwSelection } from '@/stores/useBtwStore';
const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
@@ -68,6 +69,9 @@ export interface ComposerFooterProps {
onDictationInsert: (text: string) => void;
onDictationInsertAndSend: (text: string) => void;
onDictationContentHeightChange: (height: number | null) => void;
isBtw?: boolean;
modelSessionId?: string | null;
btwSelection: BtwSelection;
}
export function ComposerFooter(props: ComposerFooterProps) {
@@ -109,6 +113,9 @@ export function ComposerFooter(props: ComposerFooterProps) {
onDictationInsert,
onDictationInsertAndSend,
onDictationContentHeightChange,
isBtw = false,
modelSessionId,
btwSelection,
} = props;
const gitProvider = useGitProvider(directory);
@@ -130,7 +137,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -142,7 +149,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -150,18 +157,18 @@ export function ComposerFooter(props: ComposerFooterProps) {
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 flex-shrink-0">
<button
{!isBtw ? <button
type="button"
className={footerIconButtonClass}
// Keep the soft keyboard open (same guard as
@@ -180,7 +187,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
aria-label={t('chat.dictation.start')}
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
</button> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -202,7 +209,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
) : (
<>
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -213,13 +220,13 @@ export function ComposerFooter(props: ComposerFooterProps) {
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
/>
<FocusModeButton
/> : null}
{!isBtw ? <FocusModeButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isExpandedInput={isExpandedInput}
onToggle={onToggleExpandedInput}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -228,19 +235,19 @@ export function ComposerFooter(props: ComposerFooterProps) {
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
withTooltip
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
withTooltip
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
<MemoComposerDictation
{isBtw ? <ModelControls className="flex-1 min-w-0 justify-end" sessionId={modelSessionId ?? null} selection={btwSelection} /> : <MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />}
{!isBtw ? <MemoComposerDictation
radius={chatInputRadius}
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -250,7 +257,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
onInsert={onDictationInsert}
onInsertAndSend={onDictationInsertAndSend}
onContentHeightChange={onDictationContentHeightChange}
/>
/> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -1,10 +1,11 @@
/**
* Where a new session will run: the project and the directory within it.
*
* Desktop uses inline selects; mobile uses bottom sheets, because a native
* select over a keyboard-resized viewport is unusable. Both render the same
* options from the same hook, and both offer creating a worktree inline so the
* user does not have to leave the draft to make one.
* Desktop uses a searchable project popup and an inline branch/worktree
* select; mobile uses bottom sheets, because a native select over a
* keyboard-resized viewport is unusable. Both render the same options from
* the same hook, and both offer creating a worktree inline so the user does
* not have to leave the draft to make one.
*/
import React from 'react';
@@ -12,8 +13,19 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { Popover } from '@base-ui/react/popover';
import { cn } from '@/lib/utils';
import { dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { handleDropdownNavigationKey, shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import { isIMECompositionEvent } from '@/lib/ime';
import {
Select,
SelectContent,
@@ -28,6 +40,7 @@ import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { shortcutRegistry } from '@/lib/shortcuts';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths';
@@ -46,6 +59,14 @@ export interface DraftTargetProps {
selectedBranchLabel: string | null;
selectedBranchIsKnown: boolean;
hasUncommittedChanges: boolean;
/**
* Whether the dirty warning may announce itself by opening its tooltip
* unprompted. Off for a draft the app opened on its own at boot: that
* draft is often only a placeholder until the last session restores, and
* a tooltip on an otherwise empty screen reads as a glitch. The warning
* icon still shows on desktop and the tooltip stays reachable by hover.
*/
announceDirtyState: boolean;
projectRootBranchOption: BranchOption | null;
worktreeBranchOptions: readonly BranchOption[];
branchItems: readonly BranchOption[];
@@ -100,26 +121,27 @@ const DIRTY_TOOLTIP_FLASH_MS = 5000;
/**
* Opens the tooltip for a few seconds when the dirty state first appears, so
* the warning is seen without hovering, then hands control back to hover.
* Only when the draft may announce itself see `announceDirtyState`.
*/
function useDirtyFlashTooltip(hasUncommittedChanges: boolean) {
function useDirtyFlashTooltip(hasUncommittedChanges: boolean, announce: boolean) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!hasUncommittedChanges) {
if (!hasUncommittedChanges || !announce) {
setOpen(false);
return;
}
setOpen(true);
const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS);
return () => window.clearTimeout(timer);
}, [hasUncommittedChanges]);
}, [announce, hasUncommittedChanges]);
return { open, onOpenChange: setOpen };
}
export function DraftTargetSelectors(props: DraftTargetProps) {
const { t } = useI18n();
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges);
const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges, props.announceDirtyState);
const {
projects,
selectedProject,
@@ -136,17 +158,53 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
theme,
} = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const [projectQuery, setProjectQuery] = React.useState('');
const [projectActiveId, setProjectActiveId] = React.useState<string | null>(null);
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
// Controlled Select closes can omit finalFocus's interaction type.
const keyboardCloseRef = React.useRef(false);
const getComposerInput = () => projectTriggerRef.current?.closest('form')?.querySelector<HTMLElement>('[data-chat-input="true"] .cm-content');
const getFinalFocus = () => keyboardCloseRef.current ? getComposerInput() : true;
const projectSearchRef = React.useRef<HTMLInputElement>(null);
// Preserve Select's dialog portal and main-area containment.
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
const [projectCollisionBoundary, setProjectCollisionBoundary] = React.useState<Element | null>(null);
const syncProjectPopupContainers = React.useCallback((target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
const dialog = element?.closest('[data-slot="dialog-content"], [role="dialog"]');
setProjectPortalContainer(dialog instanceof HTMLElement ? dialog : null);
setProjectCollisionBoundary(element?.closest('main') ?? null);
}, []);
// The popover owns no shortcut suspension (unlike Select/DropdownMenu
// wrappers), so suspend global shortcuts while the project popup is
// open and restore them on close/unmount.
const projectSuspendRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
if (openPicker !== 'project') return;
projectSuspendRef.current?.();
projectSuspendRef.current = shortcutRegistry.suspend();
return () => {
projectSuspendRef.current?.();
projectSuspendRef.current = null;
};
}, [openPicker]);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
keyboardCloseRef.current = true;
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setProjectFocusReturn(false);
setProjectQuery('');
setProjectActiveId(
projects.some((project) => project.id === selectedProject.id) ? selectedProject.id : null,
);
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
@@ -155,11 +213,43 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
setOpenPicker('worktree');
});
const filteredProjects = React.useMemo(
() => openPicker === 'project'
? rankByQuery(projects, projectQuery, (project) => [getProjectDisplayLabel(project), project.path])
: projects,
[openPicker, projects, projectQuery],
);
// Transient search must never survive to the next opening, including
// picker switches and draft unmounts.
React.useEffect(() => {
if (openPicker !== 'project') {
setProjectQuery('');
setProjectActiveId(null);
}
}, [openPicker]);
// After a query or project-list change, retain the active ID only
// while it stays visible; otherwise fall to the first result (or none
// when the list is empty) so Enter cannot commit a hidden row.
React.useEffect(() => {
if (openPicker !== 'project') return;
setProjectActiveId((current) => {
if (current && filteredProjects.some((project) => project.id === current)) return current;
return filteredProjects[0]?.id ?? null;
});
}, [filteredProjects, openPicker]);
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setProjectFocusReturn(true);
setOpenPicker(null);
};
const handleProjectSelect = (projectId: string) => {
if (!filteredProjects.some((project) => project.id === projectId)) return;
handleProjectChange(projectId);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
@@ -167,39 +257,161 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
{/* Plain popover (not a menu): the popup owns a combobox +
listbox, so menu/menuitem semantics would be wrong here. */}
<Popover.Root
open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
onOpenChange={(open, eventDetails) => {
if (open) {
setProjectFocusReturn(false);
// Seed the active row from the committed project so
// Enter without typing repeats the current choice.
// The clamp below keeps it when still visible and
// falls to the first result otherwise.
setProjectQuery('');
setProjectActiveId(
projects.some((project) => project.id === selectedProject.id)
? selectedProject.id
: null,
);
setOpenPicker('project');
return;
}
setProjectQuery('');
setProjectActiveId(null);
const reason = eventDetails?.reason;
setProjectFocusReturn(reason === 'escape-key');
setOpenPicker(null);
}}
onOpenChangeComplete={(open) => {
// Return focus after Base UI finishes closing so typing
// continues in this form's composer, including reselection.
if (!open && projectFocusReturn) {
(getComposerInput() ?? projectTriggerRef.current)?.focus();
setProjectFocusReturn(false);
}
// Focus the search once the popup mounts; the opening
// shortcut focuses the trigger first.
if (open && openPicker === 'project') projectSearchRef.current?.focus();
}}
>
<SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
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"
<Popover.Trigger
render={
<Button
ref={projectTriggerRef}
variant="ghost"
size="sm"
aria-haspopup="dialog"
className="h-7 min-w-0 w-fit max-w-[42vw] justify-start gap-1 px-1.5 normal-case sm:max-w-[18rem]"
onPointerDownCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
onFocusCapture={(event) => syncProjectPopupContainers(event.currentTarget)}
/>
}
>
<SelectValue>
<span className="flex min-w-0 items-center gap-1.5">
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
</Select>
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
</span>
</Popover.Trigger>
<Popover.Portal container={projectPortalContainer ?? undefined}>
{/* side="bottom" anchors the popup's top edge at the
trigger: the search field stays fixed at the
selector's level while filtering, and only the
results area below changes height. Disabling side
flips keeps the input stationary during filtering.
Horizontal shifting keeps the popup inside main. The
--available-height cap comes free from the shared
popup class, so the list scrolls within the space
below the trigger. */}
<Popover.Positioner
side="bottom"
align="start"
sideOffset={4}
collisionAvoidance={{ side: 'none' }}
collisionBoundary={projectCollisionBoundary ?? undefined}
className="app-region-no-drag z-50"
>
<Popover.Popup
role="dialog"
aria-label={t('chat.chatInput.draftPicker.projectTitle')}
className={cn(dropdownMenuPopupClass, 'flex w-72 max-w-[calc(100vw-2rem)] flex-col p-0')}
initialFocus={false}
finalFocus={false}
>
{/* Filtering and ordering are owned by rankByQuery above;
cmdk's own filter would re-filter and reorder the
already-ranked rows. */}
<Command
className="min-h-0 flex-1"
shouldFilter={false}
value={projectActiveId ?? undefined}
onValueChange={setProjectActiveId}
>
<CommandInput
ref={projectSearchRef}
aria-label={t('chat.chatInput.draftPicker.searchProjects')}
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
value={projectQuery}
onValueChange={setProjectQuery}
onKeyDown={(event) => {
// Command owns active-item navigation, so only
// translate the repository's Ctrl+N/P
// convention into arrows at the input.
// IME-composing keys must never move the
// active row or dismiss the popup.
if (isIMECompositionEvent(event)) {
event.stopPropagation();
return;
}
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
}}
/>
<CommandList label={t('chat.chatInput.draftPicker.projectTitle')}>
{filteredProjects.length === 0 ? (
<div role="status" className="px-3 py-6 text-center typography-ui-label text-muted-foreground">
{t('chat.chatInput.draftPicker.noProjectsFound')}
</div>
) : (
filteredProjects.map((project) => (
<CommandItem
key={project.id}
value={project.id}
onSelect={handleProjectSelect}
aria-current={project.id === selectedProject.id ? true : undefined}
className="max-w-full"
>
<span className="min-w-0 flex-1 truncate">
<ProjectLabel project={project} theme={theme} />
</span>
{project.id === selectedProject.id ? (
<Icon name="check" className="size-4 shrink-0 text-muted-foreground" />
) : null}
</CommandItem>
))
)}
</CommandList>
</Command>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
{showBranchSelector ? (
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onOpenChange={(open, details) => {
keyboardCloseRef.current = !open && details.event.type === 'keydown';
setOpenPicker(open ? 'worktree' : null);
}}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
@@ -229,7 +441,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown} finalFocus={getFinalFocus}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -271,12 +483,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
& { onOpenPicker: (picker: 'project' | 'branch') => void },
) {
const { t } = useI18n();
const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props;
const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges);
const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props;
return (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -286,35 +497,19 @@ export function MobileDraftTargetTriggers(
onClick={() => onOpenPicker('project')}
>
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
? <span className="truncate typography-ui-label">{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 ? (
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] 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('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] 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('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
) : null}
</div>
);
@@ -103,8 +103,8 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
code: 'rounded-[6px] bg-[var(--surface-muted)]',
codeFence: 'bg-[var(--surface-subtle)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
// of thing.
@@ -1,10 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveRealContentEndOffset,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
@@ -48,142 +45,6 @@ describe('getRowBottom', () => {
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveRealContentEndOffset', () => {
test('puts the last row bottom just above the composer overlay', () => {
const state = buildState({
@@ -209,18 +70,14 @@ describe('resolveRealContentEndOffset', () => {
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180 })).toBe(0);
});
test('reserves extra slack below the content when asked', () => {
test('counts the footer rendered after the last row as real content', () => {
const state = buildState({
positions: [0, 1000],
sizes: [1000, 200],
scrollLength: 700,
});
expect(resolveRealContentEndOffset({
state,
composerOverlayHeight: 180,
extraInset: CHAT_LIST_ANCHOR_OFFSET,
})).toBe(696);
expect(resolveRealContentEndOffset({ state, composerOverlayHeight: 180, footerSize: 120 })).toBe(800);
});
test('returns null for an empty timeline and for unmeasured last rows', () => {
@@ -237,10 +94,13 @@ describe('resolveRealContentEndOffset', () => {
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
test('uses a 40px band regardless of viewport height', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1360, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1359, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1100, scrollLength: 600 })).toBe(false);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1900, scrollLength: 60 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1899, scrollLength: 60 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
@@ -252,29 +112,3 @@ describe('resolveTimelineIsAtEnd', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -1,29 +1,17 @@
// Anchored-turn scroll geometry for the chat timeline.
// Scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
// The timeline has two mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// • `following-end` — stay pinned to the live edge as content grows.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
// list and answers where the real content ends and whether the viewport is
// there. Keeping it free of DOM and React makes the rules testable without a
// renderer.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export type TimelineScrollMode = 'following-end' | 'free-scrolling';
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
@@ -33,17 +21,6 @@ export interface TimelineListMeasurementState {
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
@@ -58,86 +35,33 @@ export const getRowBottom = (
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
// Rows measured at zero height would read as no content at all; treat
// them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// The scroll offset that puts the LAST REAL ROW's bottom just above the
// composer overlay. Distinct from the list's own end offset, which is derived
// from the total content length: that length includes any reserved anchored
// end space and, right after rows re-wrap on a width change, row sizes that
// have not been re-measured yet. Scrolling to it then lands below the real
// content and leaves a blank tail. `extraInset` reserves additional slack
// below the content when a caller wants the row to sit clear of the edge.
// The list footer (question and permission cards, error notices, the tail
// spacer) renders after the last row and is part of the real content; the
// list does not expose its size through getState, so the caller passes the
// last reported value.
export const resolveRealContentEndOffset = ({
state,
composerOverlayHeight,
extraInset = 0,
footerSize = 0,
}: {
readonly state: TimelineListMeasurementState;
readonly composerOverlayHeight: number;
readonly extraInset?: number;
readonly footerSize?: number;
}): number | null => {
const lastIndex = state.data.length - 1;
if (lastIndex < 0) return null;
const lastBottom = getRowBottom(state, lastIndex);
if (lastBottom === null) return null;
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight - extraInset);
return Math.max(0, lastBottom - visibleLength);
const visibleLength = Math.max(0, state.scrollLength - composerOverlayHeight);
return Math.max(0, lastBottom + Math.max(0, footerSize) - visibleLength);
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
// Keep return-to-end detection in a tight band, rather than half a viewport.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
@@ -161,31 +85,3 @@ export const resolveTimelineIsAtEnd = (
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,201 @@
import { describe, expect, test } from 'bun:test';
import type { AssistantMessage, Part, ToolPart, ToolStateCompleted } from '@opencode-ai/sdk/v2';
import { getLiveFinalMessage, getTurnsWithLaterAssistant, hasLiveActivity } from './liveActivity';
import { projectTurnRecords } from './projectTurnRecords';
import { summarizeLiveActivity } from './liveActivitySummary';
import type { ChatMessageEntry } from './types';
function assistant(id: string, parts: Part[], options: Partial<AssistantMessage> = {}): ChatMessageEntry {
return {
info: {
id, sessionID: 'session', role: 'assistant', parentID: 'user',
time: { created: 2 }, modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build',
path: { cwd: '/project', root: '/project' }, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...options,
},
parts,
};
}
function user(id = 'user', hidden = false): ChatMessageEntry {
return {
info: { id, sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } },
parts: hidden ? [] : [text(`Request ${id}`)],
};
}
function text(content: string): Part {
return { type: 'text', id: content, messageID: 'message', sessionID: 'session', text: content };
}
function tool(id: string, name: string, options: {
status?: 'completed' | 'error' | 'running';
input?: ToolStateCompleted['input'];
metadata?: ToolStateCompleted['metadata'];
error?: string;
} = {}): ToolPart {
const common = { input: options.input ?? {}, metadata: options.metadata ?? {}, time: { start: 1, end: 2 } };
const state: ToolPart['state'] = options.status === 'error'
? { ...common, status: 'error', error: options.error ?? 'failed' }
: options.status === 'running'
? { ...common, status: 'running' }
: { ...common, status: 'completed', output: '', title: name };
return {
id, callID: id, type: 'tool', tool: name, messageID: 'message', sessionID: 'session',
state,
};
}
const diff = '@@ -1,1 +1,2 @@\n-before\n+after\n+added';
describe('live turn boundaries', () => {
test('a queued user message does not collapse the previous turn', () => {
const turns = projectTurnRecords([user(), assistant('a', [tool('read', 'read')]), user('next')]).turns;
expect(getTurnsWithLaterAssistant(turns).size).toBe(0);
});
test('an assistant with the next visible parent retires the previous turn', () => {
const turns = projectTurnRecords([
user(), assistant('a', [text('Checking'), tool('read', 'read')]), user('next'),
assistant('b', [tool('bash', 'bash')], { parentID: 'next' }),
]).turns;
expect([...getTurnsWithLaterAssistant(turns)]).toEqual(['user']);
expect(getLiveFinalMessage(turns[0].assistantMessages)).toBeUndefined();
});
test('hidden user continuations keep their visible turn open', () => {
const turns = projectTurnRecords([
user(), assistant('a', [tool('read', 'read')]), user('hidden', true),
assistant('b', [tool('bash', 'bash')], { parentID: 'hidden' }),
], { mergeHiddenUserTurns: { planModeEnabled: false } }).turns;
expect(turns).toHaveLength(1);
expect(getTurnsWithLaterAssistant(turns).size).toBe(0);
});
test('only stop text is a final answer, not a tool step, compaction or earlier stop', () => {
const final = assistant('final', [text('Done')], { finish: 'stop' });
expect(getLiveFinalMessage([final])).toBe(final);
expect(getLiveFinalMessage([assistant('progress', [text('Checking')], { finish: 'tool-calls' })])).toBeUndefined();
expect(getLiveFinalMessage([assistant('compact', [text('Summary')], { finish: 'stop', summary: true })])).toBeUndefined();
expect(getLiveFinalMessage([final, assistant('continued', [tool('read', 'read')])])).toBeUndefined();
expect(getLiveFinalMessage([assistant('question', [text('Which?'), tool('question', 'question', { status: 'running' })])])).toBeUndefined();
});
test('activity eligibility follows visible sorted activity rather than any assistant prose', () => {
const reasoning: Part = { type: 'reasoning', id: 'thinking', messageID: 'a', sessionID: 'session', text: 'Thinking', time: { start: 1, end: 2 } };
const turn = projectTurnRecords([user(), assistant('a', [reasoning, text('Done')], { finish: 'stop' })]).turns[0];
expect(hasLiveActivity(turn, false)).toBe(false);
expect(hasLiveActivity(turn, true)).toBe(true);
expect(hasLiveActivity(projectTurnRecords([user(), assistant('a', [text('Hello')], { finish: 'stop' })]).turns[0], true)).toBe(false);
});
});
describe('live activity report', () => {
test('groups exploration and web calls without pretending their counts are file counts', () => {
const result = summarizeLiveActivity([assistant('a', [
...['read', 'list', 'glob', 'grep', 'lsp', 'skill', 'webfetch', 'websearch', 'codesearch', 'perplexity'].map((name) => tool(name, name)),
tool('bash1', 'bash', { input: { command: 'first && second' } }),
tool('bash2', 'bash'),
tool('task1', 'task', { metadata: { sessionId: 'child' } }),
tool('task2', 'task', { metadata: { sessionId: 'child' } }),
tool('task3', 'task', { metadata: { sessionId: 'other-child' } }),
])]);
expect(result).toMatchObject({ explored: true, researched: true, commands: 2, subagents: 2, files: 0 });
});
test('does not invent meanings for managed tools, MCP names or unknown aliases', () => {
const result = summarizeLiveActivity([assistant('a', [
...['question', 'todowrite', 'plan_exit', 'StructuredOutput', 'openchamber', 'openchamber_web', 'openchamber_memory', 'linear_save_issue', 'mcp.edit'].map((name) => tool(name, name)),
])]);
expect(result).toMatchObject({ explored: false, researched: false, commands: 0, subagents: 0, files: 0 });
});
test('counts each command call once, including a confirmed nonzero exit but not a permission refusal', () => {
const command = tool('bash', 'bash');
const result = summarizeLiveActivity([assistant('a', [
command, command,
tool('failed', 'bash', { status: 'error', error: 'exit 1', metadata: { exit: 1 } }),
tool('denied', 'bash', { status: 'error', error: 'Permission denied' }),
tool('running', 'bash', { status: 'running' }),
])]);
expect(result.commands).toBe(2);
});
test('sums actual call diffs while deduplicating paths and duplicate call records', () => {
const first = tool('edit1', 'edit', { input: { filePath: './src/a.ts' }, metadata: { diff } });
const result = summarizeLiveActivity([assistant('a', [
first, first,
tool('edit2', 'edit', { input: { filePath: '/project/src/a.ts' }, metadata: { diff: '@@ -1,1 +1,0 @@\n-after' } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 2, deletions: 2, hasCompleteDiff: true });
});
test('takes all patch files and never double-counts their top-level diff', () => {
const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: {
diff,
files: [
{ filePath: '/project/a', patch: diff, type: 'update' },
{ filePath: '/project/b', diff: '@@ -1,1 +1,0 @@\n-deleted', type: 'delete' },
{ filePath: '/project/c', additions: 3, deletions: 0, type: 'add' },
],
} })])]);
expect(result).toMatchObject({ files: 3, additions: 5, deletions: 2, hasCompleteDiff: true });
});
test('a rename preserves the identity of a file already edited in the turn', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('edit', 'edit', { input: { filePath: 'old.ts' }, metadata: { diff } }),
tool('move', 'apply_patch', { metadata: { files: [{ filePath: '/project/old.ts', movePath: '/project/new.ts', additions: 0, deletions: 0 }] } }),
tool('edit-again', 'edit', { input: { filePath: 'new.ts' }, metadata: { diff } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 4, deletions: 2 });
});
test('uses the whole-call diff when per-file stats are missing, without adding partial numbers', () => {
const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: {
diff: `${diff}\n@@ -1,1 +1,0 @@\n-deleted`,
files: [{ filePath: '/project/a', patch: diff }, { filePath: '/project/b' }],
} })])]);
expect(result).toMatchObject({ files: 2, additions: 2, deletions: 2, hasCompleteDiff: true });
});
test('write content is not a diff and partial stats are not shown as a complete total', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff } }),
tool('write', 'write', { input: { filePath: 'b', content: 'one\ntwo\nthree' } }),
])]);
expect(result).toMatchObject({ files: 2, hasCompleteDiff: false });
});
test('rejects truncated diff counts and counts source lines resembling diff headers', () => {
expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+incomplete' } })])]).hasCompleteDiff).toBe(false);
expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,1 @@\n---source\n+++source' } })])])).toMatchObject({ additions: 1, deletions: 1 });
});
test('failed edits and malformed metadata cannot erase another valid change', () => {
const result = summarizeLiveActivity([assistant('a', [
tool('bad', 'edit', { status: 'error', error: 'failed', input: { filePath: 'bad' }, metadata: { diff } }),
tool('good', 'edit', { input: { filePath: 'good' }, metadata: { diff, files: 'invalid' } }),
])]);
expect(result).toMatchObject({ files: 1, additions: 2, deletions: 1, hasCompleteDiff: true });
});
test('normalizes absolute dot segments and Windows path spelling', () => {
expect(summarizeLiveActivity([assistant('unix', [
tool('one', 'edit', { input: { filePath: '/project/src/../a' }, metadata: { diff } }),
tool('two', 'edit', { input: { filePath: 'a' }, metadata: { diff } }),
])]).files).toBe(1);
expect(summarizeLiveActivity([assistant('windows', [
tool('one', 'edit', { input: { filePath: 'C:\\Project\\A.ts' }, metadata: { diff } }),
tool('two', 'edit', { input: { filePath: 'c:/project/./a.ts' }, metadata: { diff } }),
], { path: { cwd: 'C:/Project', root: 'C:/Project' } })]).files).toBe(1);
});
test('a confirmed no-op is not a changed file, but creating an empty file is', () => {
expect(summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: { files: [
{ filePath: '/project/noop', additions: 0, deletions: 0, type: 'update' },
{ filePath: '/project/empty', additions: 0, deletions: 0, type: 'add' },
] } })])])).toMatchObject({ files: 1, additions: 0, deletions: 0, hasCompleteDiff: true });
});
});
@@ -0,0 +1,28 @@
import type { ChatMessageEntry, TurnRecord } from './types';
/** A queued user message alone does not retire the previous turn. */
export function getTurnsWithLaterAssistant(turns: readonly TurnRecord[]): Set<string> {
const retired = new Set<string>();
let hasLaterAssistant = false;
for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index];
if (hasLaterAssistant) retired.add(turn.turnId);
hasLaterAssistant ||= turn.assistantMessages.length > 0;
}
return retired;
}
export function getLiveFinalMessage(messages: readonly ChatMessageEntry[]): ChatMessageEntry | undefined {
const last = messages.at(-1);
// Do not use projectTurnSummary's intermediate-text fallback. Compaction
// summaries are not user-facing final answers either.
return last?.info.role === 'assistant' && last.info.finish === 'stop' && !last.info.summary
&& last.parts.some((part) => part.type === 'text' && part.text.trim().length > 0)
? last : undefined;
}
export function hasLiveActivity(turn: TurnRecord, showReasoning: boolean): boolean {
return turn.activitySegments.some((segment) => segment.parts.some((activity) => (
activity.kind === 'tool' || (showReasoning && activity.kind === 'reasoning')
)));
}
@@ -0,0 +1,179 @@
import { z } from 'zod';
import { normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
import type { ChatMessageEntry } from './types';
const patchTextSchema = z.string().regex(/\S/);
const patchSchema = z.union([patchTextSchema, z.object({ patch: patchTextSchema }).transform((value) => value.patch)]);
const optionalText = z.string().trim().min(1).optional().catch(undefined);
const optionalCount = z.number().int().nonnegative().optional().catch(undefined);
const fileSchema = z.object({
file: optionalText,
filePath: optionalText,
relativePath: optionalText,
movePath: optionalText,
type: optionalText,
patch: patchSchema.optional().catch(undefined),
diff: patchSchema.optional().catch(undefined),
additions: optionalCount,
deletions: optionalCount,
});
// Tool metadata is an external boundary. Parse only the fields whose meaning
// is established by our edit/patch renderers; unrelated or malformed fields
// must not erase other valid calls from the report.
const metadataSchema = z.object({
files: z.array(fileSchema.nullable().catch(null)).optional().catch(undefined),
filediff: fileSchema.optional().catch(undefined),
patch: patchSchema.optional().catch(undefined),
diff: patchSchema.optional().catch(undefined),
sessionId: optionalText,
exit: z.number().optional().catch(undefined),
});
const inputSchema = z.object({
filePath: optionalText,
file_path: optionalText,
path: optionalText,
});
const changeTools = new Set(['edit', 'multiedit', 'write', 'apply_patch']);
const explorationTools = new Set(['read', 'list', 'grep', 'glob', 'lsp', 'skill']);
const webTools = new Set(['websearch', 'perplexity', 'codesearch', 'webfetch']);
const commandTools = new Set(['bash', 'shell', 'cmd', 'terminal']);
export interface LiveActivitySummary {
files: number;
additions: number;
deletions: number;
hasCompleteDiff: boolean;
explored: boolean;
commands: number;
researched: boolean;
subagents: number;
}
function countPatch(patch: string | undefined): { additions: number; deletions: number } | undefined {
if (!patch) return undefined;
let additions = 0;
let deletions = 0;
let hasHunk = false;
let oldRemaining = 0;
let newRemaining = 0;
// Count hunk bodies, not file headers. A source line beginning with ++ or
// -- is still a real added/deleted line inside a hunk.
for (const line of patch.split('\n')) {
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (hunk) {
if (oldRemaining !== 0 || newRemaining !== 0) return undefined;
hasHunk = true;
oldRemaining = Number(hunk[2] ?? 1);
newRemaining = Number(hunk[4] ?? 1);
} else if (oldRemaining > 0 || newRemaining > 0) {
if (line.startsWith('+') && newRemaining > 0) {
additions++;
newRemaining--;
} else if (line.startsWith('-') && oldRemaining > 0) {
deletions++;
oldRemaining--;
} else if (line.startsWith(' ') && oldRemaining > 0 && newRemaining > 0) {
oldRemaining--;
newRemaining--;
} else if (!line.startsWith('\\ No newline')) {
return undefined;
}
}
}
return hasHunk && oldRemaining === 0 && newRemaining === 0 ? { additions, deletions } : undefined;
}
export function summarizeLiveActivity(messages: readonly ChatMessageEntry[]): LiveActivitySummary {
const summary: LiveActivitySummary = {
files: 0, additions: 0, deletions: 0, hasCompleteDiff: true,
explored: false, commands: 0, researched: false, subagents: 0,
};
const changedFiles = new Set<string>();
const subagents = new Set<string>();
const seenCalls = new Set<string>();
for (const message of messages) {
const cwd = message.info.role === 'assistant' ? message.info.path?.cwd ?? '' : '';
const resolvePath = (path: string) => {
const absolute = normalizeFilePath(cwd ? toAbsoluteFilePath(cwd, path) : path);
if (/^[A-Za-z]:\//.test(absolute)) {
return toAbsoluteFilePath(absolute.slice(0, 3), absolute.slice(3)).toLowerCase();
}
if (absolute.startsWith('//')) {
const [server, share, ...parts] = absolute.slice(2).split('/');
return toAbsoluteFilePath(`//${server}/${share}`, parts.join('/')).toLowerCase();
}
return absolute.startsWith('/') ? toAbsoluteFilePath('/', absolute.slice(1)) : absolute;
};
for (const part of message.parts) {
if (part.type !== 'tool') continue;
const callKey = `${message.info.id}:${part.callID || part.id}`;
if (seenCalls.has(callKey)) continue;
seenCalls.add(callKey);
const state = part.state;
if (state.status !== 'completed' && state.status !== 'error') continue;
const tool = part.tool.trim().toLowerCase();
const metadata = metadataSchema.safeParse(state.metadata).data;
if (commandTools.has(tool) && (state.status === 'completed' || metadata?.exit !== undefined)) {
summary.commands++;
}
if (state.status !== 'completed') continue;
summary.explored ||= explorationTools.has(tool);
summary.researched ||= webTools.has(tool);
if (tool === 'task' && metadata?.sessionId) subagents.add(metadata.sessionId);
if (!changeTools.has(tool)) continue;
const input = inputSchema.safeParse(state.input).data;
if (metadata?.files?.some((file) => file === null)) summary.hasCompleteDiff = false;
const entries = metadata?.files?.filter((file) => file !== null);
const files = entries?.length ? entries : [metadata?.filediff ?? {}];
let missingFileDiff = false;
let callAdditions = 0;
let callDeletions = 0;
const callPaths = new Set<string>();
for (const file of files) {
const originalPath = file.filePath ?? file.file ?? file.relativePath
?? (tool !== 'apply_patch' ? input?.filePath ?? input?.file_path ?? input?.path : undefined);
const path = file.movePath ?? originalPath;
if (!path) {
summary.hasCompleteDiff = false;
continue;
}
const normalizedPath = resolvePath(path);
if (callPaths.has(normalizedPath)) continue;
callPaths.add(normalizedPath);
const stats = countPatch(file.patch ?? file.diff)
?? (file.additions !== undefined && file.deletions !== undefined
? { additions: file.additions, deletions: file.deletions } : undefined);
if (stats && stats.additions === 0 && stats.deletions === 0
&& !file.movePath && file.type !== 'add' && file.type !== 'delete') continue;
// A rename moves an existing identity rather than counting it
// again when the same file was edited earlier in this turn.
if (file.movePath && originalPath) changedFiles.delete(resolvePath(originalPath));
changedFiles.add(normalizedPath);
if (!stats) {
missingFileDiff = true;
} else {
callAdditions += stats.additions;
callDeletions += stats.deletions;
}
}
if (missingFileDiff) {
// The top-level patch describes the entire call. Use it instead
// of (never in addition to) any per-file numbers already found.
const fallback = countPatch(metadata?.patch ?? metadata?.diff);
if (fallback) {
callAdditions = fallback.additions;
callDeletions = fallback.deletions;
} else {
summary.hasCompleteDiff = false;
}
}
summary.additions += callAdditions;
summary.deletions += callDeletions;
}
}
summary.files = changedFiles.size;
summary.subagents = subagents.size;
return summary;
}
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
activityOwnerMessageId?: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
hasEarlierAssistantText?: boolean;
isLatestTurn: boolean;
summaryBody?: string;
activityParts?: TurnActivityRecord[];
@@ -52,6 +52,7 @@ const ICONS = {
fit: 'refresh',
textWrap: 'text-wrap',
image: 'file-image',
disclosure: 'arrow-right-s',
} as const satisfies Record<string, IconName>;
const ICON_BTN_CLASS =
@@ -81,6 +82,19 @@ const decorateImageLabels = (root: HTMLElement): void => {
}
};
const decorateDisclosures = (root: HTMLElement): void => {
for (const summary of root.querySelectorAll<HTMLElement>('details[data-md-details] > summary')) {
if (summary.querySelector('[data-md-disclosure-icon]')) continue;
const label = document.createElement('span');
label.append(...Array.from(summary.childNodes));
const icon = document.createElement('span');
icon.setAttribute('data-md-disclosure-icon', '');
icon.setAttribute('aria-hidden', 'true');
setIcon(icon, 'disclosure');
summary.append(icon, label);
}
};
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
const button = document.createElement('button');
button.type = 'button';
@@ -344,7 +358,7 @@ const decorateTables = (root: HTMLElement, labels: DecorateLabels): void => {
if (existing) continue;
const wrapper = document.createElement('div');
wrapper.className = 'group my-4 flex flex-col space-y-2';
wrapper.className = 'group my-4 flex w-fit max-w-full flex-col space-y-2';
wrapper.setAttribute('data-markdown', 'table-wrapper');
const toolbar = document.createElement('div');
@@ -611,6 +625,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
decorateDisclosures(root);
decorateImageLabels(root);
decorateInlineCode(root);
decorateMermaid(root, ctx);
@@ -105,6 +105,61 @@ describe('markdown sanitization', () => {
});
describe('Markdown disclosures', () => {
test('renders summaries and rich Markdown without allowing raw HTML attributes', () => {
const html = renderMarkdownSync('<details open><summary>Review **ready**</summary>\n\n> Quoted review\n\n1. First\n2. Second\n\n```sh\nbun test\n```\n\n</details>\n\nAfter');
expect(html).toContain('<details data-md-details open>');
expect(html).toContain('<summary>Review <strong>ready</strong></summary>');
expect(html).toContain('<blockquote>');
expect(html).toContain('<ol>');
expect(html).toContain('<code class="language-sh">bun test');
expect(html).toContain('</details><p>After</p>');
const unsafe = renderMarkdownSync('<details onclick="alert(1)"><summary>Unsafe</summary>text</details>');
expect(unsafe).not.toContain('<details');
expect(unsafe).toContain('&lt;details');
expect(renderMarkdownSync('<details><summary>Safe</summary>\n\n<style>body{display:none}</style>\n\n</details>')).not.toContain('<style>');
});
test('keeps nested disclosures and literal closing tags inside code in their owner', () => {
const source = '<details><summary>Outer</summary>\n\n`</details>`\n\n```html\n</details>\n```\n\n<details open><summary>Inner</summary>\n\n**Nested**\n\n</details>\n\nOuter end\n\n</details>\n\nAfter';
const html = renderMarkdownSync(source);
expect(html.match(/<details /g)).toHaveLength(2);
expect(html).toContain('<code>&lt;/details&gt;</code>');
expect(html).toContain('<strong>Nested</strong>');
expect(html).toContain('</details><p>Outer end</p>');
expect(html).toContain('</details><p>After</p>');
expect(renderMarkdownSync('```html\n<details><summary>Example</summary></details>\n```')).not.toContain('<details');
});
test('keeps streamed bodies together and settled leading blocks cache-stable', async () => {
const prefix = 'Introduction\n\n<details><summary>Review</summary>\n\n';
const first = await renderMarkdownBlocks(`${prefix}> First\n\n1. Item`, true);
const next = await renderMarkdownBlocks(`${prefix}> First\n\n1. Item\n2. More\n\n\`\`\`sh\nbun test`, true);
expect(first).toHaveLength(2);
expect(next).toHaveLength(2);
expect(next[0]).toEqual(first[0]);
expect(next[1]?.html).toContain('<details data-md-details>');
expect(next[1]?.html).toContain('<li>More</li>');
expect(next[1]?.html).toContain('bun test');
expect(next[1]?.html.endsWith('</details>')).toBe(true);
const finished = await renderMarkdownBlocks(`${prefix}> First\n\n</details>\n\nAfter`, true);
expect(finished).toHaveLength(3);
expect(finished[2]?.html).toContain('<p>After</p>');
});
test('handles incomplete summary and closing tag prefixes without losing content', async () => {
const source = '<details><summary>Review</summary>\n\n**Body**\n\n</details>';
for (let length = 1; length <= source.length; length += 1) {
const blocks = await renderMarkdownBlocks(source.slice(0, length), true);
const html = blocks.map((block) => block.html).join('');
if (length >= source.indexOf('\n\n')) expect(html).toContain('<details data-md-details>');
if (length >= source.indexOf('\n\n</details>')) {
expect(html).toContain('<strong>Body</strong>');
}
}
});
});
describe('Markdown block cache reads', () => {
test('returns all settled blocks synchronously after a full cache hit', async () => {
resetMarkdownHtmlCacheForTests();
@@ -1,4 +1,4 @@
import { Marked, marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens, type TokenizerAndRendererExtension } from 'marked';
import markedLinkifyIt from 'marked-linkify-it';
import remend from 'remend';
import katex from 'katex';
@@ -233,7 +233,7 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
let tokens: Tokens.Generic[];
try {
tokens = marked.lexer(text) as Tokens.Generic[];
tokens = inlineImageParser.lexer(text);
} catch {
return [{ raw: text, src: heal(text), mode: 'live', highlight: true }];
}
@@ -342,6 +342,74 @@ const blockMathExtension = {
},
};
// Own the entire disclosure token, including an unfinished streamed body. HTML
// token boundaries otherwise split it at blank lines and close the DOM early.
const detailsExtension: TokenizerAndRendererExtension = {
name: 'disclosure',
level: 'block',
start(src) {
const match = /(?:^|\n) {0,3}<details(?:\s|>)/i.exec(src);
return match ? match.index + (match[0].startsWith('\n') ? 1 : 0) : undefined;
},
tokenizer(src) {
// Only the native boolean open attribute is accepted. Never forward raw
// attributes, styles, event handlers, or an arbitrary HTML subtree.
const opening = /^ {0,3}<details(?:\s+(open(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?))?\s*>\s*<summary\s*>([\s\S]*?)<\/summary\s*>/i.exec(src);
if (!opening) return undefined;
const bodyStart = opening[0].length;
const body = src.slice(bodyStart);
const markers = /(^ {0,3}(`{3,}|~{3,})[^\n]*(?:\n|$))|(`+)|(<\/?details\b[^>]*>)/gim;
let depth = 1;
let bodyEnd = body.length;
let end = src.length;
let marker: RegExpExecArray | null;
while ((marker = markers.exec(body))) {
if (marker[2]) {
const fence = marker[2];
const close = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[\\t ]*(?:\\n|$)`, 'gm');
close.lastIndex = markers.lastIndex;
const found = close.exec(body);
if (!found) break;
markers.lastIndex = close.lastIndex;
} else if (marker[3]) {
const ticks = marker[3];
const close = /`+/g;
close.lastIndex = markers.lastIndex;
let found: RegExpExecArray | null;
while ((found = close.exec(body))) {
if (found[0].length === ticks.length) {
markers.lastIndex = close.lastIndex;
break;
}
}
} else if (marker[4]) {
const lineStart = body.lastIndexOf('\n', marker.index - 1) + 1;
const prefix = body.slice(lineStart, marker.index);
// Quoted and indented code belongs to the child Markdown parser. Its
// HTML-looking text must not terminate the surrounding disclosure.
if (/^(?: {4}|\t| {0,3}>)/.test(prefix) || /(?:^|[^\\])(?:\\\\)*\\$/.test(prefix)) continue;
depth += /^<\//.test(marker[4]) ? -1 : 1;
if (depth === 0) {
bodyEnd = marker.index;
end = bodyStart + markers.lastIndex;
break;
}
}
}
return {
type: 'disclosure',
raw: src.slice(0, end),
open: Boolean(opening[1]),
summary: this.lexer.inlineTokens(opening[2] ?? ''),
tokens: this.lexer.blockTokens(body.slice(0, bodyEnd)),
};
},
renderer(token) {
return `<details data-md-details${token.open ? ' open' : ''}><summary>${this.parser.parseInline(token.summary)}</summary>${this.parser.parse(token.tokens ?? [])}</details>`;
},
childTokens: ['summary', 'tokens'],
};
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
// Plain CJK characters right after a URL are still consumed, matching GitHub.
@@ -350,7 +418,7 @@ const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
{
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
extensions: [inlineMathExtension, blockMathExtension, detailsExtension],
renderer: {
// Assistant output is untrusted. Markdown constructs still render as HTML,
// but raw HTML must remain visible text so it cannot introduce active DOM
@@ -1,4 +1,4 @@
/** Raw HTML in assistant markdown is untrusted and must stay inert text. */
/** Raw HTML stays inert; supported disclosures are constructed by the Markdown tokenizer. */
export const escapeRawMarkdownHtml = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -26,6 +26,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
import { useMessageTTS } from '@/hooks/useMessageTTS';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useFactsFit } from './useFactsFit';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { TextSelectionMenu } from './TextSelectionMenu';
@@ -40,6 +42,8 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { LiveActivityCollapse } from '../components/LiveActivityCollapse';
import { LiveFinalActivityContext } from '../components/liveActivityContext';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -468,7 +472,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
alwaysShowActions?: boolean;
hasTouchInput?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean;
onShowPopup: (content: ToolPopupContent) => void;
agentMention?: AgentMentionInfo;
@@ -571,6 +575,51 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
);
const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork;
const [userActionSheetOpen, setUserActionSheetOpen] = React.useState(false);
const userSheetActions = React.useMemo(() => {
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
if (canCopyMessage && hasCopyableText && onCopyMessage) {
actions.push({
id: 'copy',
label: t('chat.messageBody.actions.copyMessage'),
icon: <Icon name="file-copy" className="h-4 w-4" />,
// The sheet closes on tap, so the button's own tick has nowhere
// to land — say it with a toast instead.
onSelect: () => {
void (async () => {
const copied = await onCopyMessage();
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
})();
},
});
}
if (onToggleContextPin && hasCopyableText) {
actions.push({
id: 'pin-context',
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
disabled: contextPinPending,
onSelect: () => { onToggleContextPin(); },
});
}
if (effectiveOnFork) {
actions.push({
id: 'fork',
label: t('chat.messageBody.actions.fork'),
icon: <Icon name="git-branch" className="h-4 w-4" />,
onSelect: () => { effectiveOnFork(); },
});
}
if (onRevert) {
actions.push({
id: 'revert',
label: t('chat.messageBody.actions.revert'),
icon: <Icon name="arrow-go-back" className="h-4 w-4" />,
onSelect: () => { onRevert(); },
});
}
return actions;
}, [canCopyMessage, contextPinPending, contextPinned, effectiveOnFork, hasCopyableText, onCopyMessage, onRevert, onToggleContextPin, t]);
const timestamp = React.useMemo(() => {
void locale;
if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null;
@@ -592,7 +641,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
)}>
<div
className={cn(
'flex items-center justify-end gap-1',
'flex items-center justify-end gap-1.5 [&_button]:!h-[26px] [&_button]:!w-[26px] [&_svg]:!size-3.5',
isMobile
? userActionsMode === 'inline'
? 'translate-x-5'
@@ -605,7 +654,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
)}
>
{timestamp ? (
{/* Touch reads the time in the actions sheet instead — see below. */}
{timestamp && !alwaysShowActions ? (
<Tooltip>
<TooltipTrigger asChild>
<span
@@ -613,106 +663,161 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
aria-label={`Message time: ${timestamp}`}
>
<Icon name="time" className="h-3.5 w-3.5" />
<span className="message-footer__label">{timestamp}</span>
<span>{timestamp}</span>
</span>
</TooltipTrigger>
<TooltipContent>{timestamp}</TooltipContent>
</Tooltip>
) : null}
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
{/* Touch has no hover, so the row would stand open under every
message. One button and a labelled sheet instead the same
shape the assistant footer uses. */}
{alwaysShowActions ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<Icon name="arrow-go-back" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{effectiveOnFork && (
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.moreActions')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setUserActionSheetOpen(true);
}}
>
<Icon name="more" className="h-3.5 w-3.5" />
</Button>
<MobileOverlayPanel
open={userActionSheetOpen}
onClose={() => setUserActionSheetOpen(false)}
title={t('chat.messageBody.actions.moreActions')}
>
<div className="flex flex-col">
{timestamp ? (
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
<Icon name="time" className="h-4 w-4" />
<span className="typography-ui-label">{timestamp}</span>
</div>
) : null}
{userSheetActions.map((action) => (
<button
key={action.id}
type="button"
disabled={action.disabled}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => {
setUserActionSheetOpen(false);
action.onSelect();
}}
style={{ touchAction: 'manipulation' }}
>
<span className="text-muted-foreground">{action.icon}</span>
<span className="typography-ui-label">{action.label}</span>
</button>
))}
</div>
</MobileOverlayPanel>
</>
) : (
<>
{onRevert && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
effectiveOnFork();
}}
>
<Icon name="git-branch" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{onToggleContextPin && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<Icon name="file-copy" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.revertAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<Icon name="arrow-go-back" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
</Tooltip>
)}
{effectiveOnFork && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.forkAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
effectiveOnFork();
}}
>
<Icon name="git-branch" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
</Tooltip>
)}
{onToggleContextPin && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
aria-pressed={contextPinned}
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.copyMessageAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<Icon name="file-copy" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
</div>
) : null;
@@ -979,7 +1084,7 @@ const AssistantMessageActionButtons = React.memo(({
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
!hasCopyableText && 'opacity-50'
)}
disabled={!hasCopyableText}
@@ -1001,9 +1106,9 @@ const AssistantMessageActionButtons = React.memo(({
}}
>
{isMessageCopied ? (
<Icon name="check" className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<Icon name="file-copy" className="h-3.5 w-3.5" />
<Icon name="file-copy" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
@@ -1019,7 +1124,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
disabled={isTransferringReview || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isTransferringReview) && 'opacity-50'
)}
aria-label={reviewTransferAction.ariaLabel}
@@ -1029,9 +1134,9 @@ const AssistantMessageActionButtons = React.memo(({
}}
>
{isTransferringReview ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
<Icon name="loader-4" className="h-3 w-3 animate-spin" />
) : (
<Icon name="arrow-left-right" className="h-4 w-4" />
<Icon name="arrow-left-right" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
@@ -1046,7 +1151,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
disabled={isSharing || !hasCopyableText}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || isSharing) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
@@ -1055,9 +1160,9 @@ const AssistantMessageActionButtons = React.memo(({
}}
>
{isSharing ? (
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
<Icon name="loader-4" className="h-3 w-3 animate-spin" />
) : (
<Icon name="image-download" className="h-4 w-4" />
<Icon name="image-download" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
@@ -1071,7 +1176,7 @@ const AssistantMessageActionButtons = React.memo(({
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
)}
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
@@ -1079,9 +1184,9 @@ const AssistantMessageActionButtons = React.memo(({
onClick={handleTTSClick}
>
{isTTSPlaying ? (
<Icon name="stop" className="h-3.5 w-3.5" />
<Icon name="stop" className="h-3 w-3" />
) : (
<Icon name="volume-up" className="h-3.5 w-3.5" />
<Icon name="volume-up" className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
@@ -1326,6 +1431,7 @@ const AssistantMessageBody = React.memo(({
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const vscodeApi = useRuntimeAPIs().vscode;
const isSortedRenderMode = chatRenderMode === 'sorted';
const liveFinalActivity = React.useContext(LiveFinalActivityContext);
const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop';
@@ -1372,9 +1478,10 @@ const AssistantMessageBody = React.memo(({
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
// Optional event: the footer's action sheet calls this without one.
(event?: React.MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
event?.preventDefault();
if (!assistantPlanText.trim()) {
return;
}
@@ -1435,9 +1542,10 @@ const AssistantMessageBody = React.memo(({
);
const handleSaveAsPlanClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
// Optional event: the footer's action sheet calls this without one.
(event?: React.MouseEvent<HTMLButtonElement>) => {
event?.stopPropagation();
event?.preventDefault();
if (!assistantPlanText.trim()) {
return;
}
@@ -1729,7 +1837,21 @@ const AssistantMessageBody = React.memo(({
const shouldRenderStandaloneActionsAfterContent = shouldShowStandaloneMessageActions && lastRenderableTextPartIndex < 0;
const renderedParts = React.useMemo(() => {
const rendered: React.ReactNode[] = [];
const answerRendered: React.ReactNode[] = [];
const activityRendered: React.ReactNode[] = [];
let rendered = answerRendered;
const splitLiveActivity = !isSortedRenderMode && liveFinalActivity?.messageId === messageId && hasStopFinish;
let hasRenderedAnswerText = false;
const isFinalLiveAnswer = chatRenderMode === 'live' && isLastAssistantInTurn && hasStopFinish;
const hasEarlierVisibleActivity = isFinalLiveAnswer && Boolean(turnGroupingContext?.activityParts?.some((activity) => {
if (activity.messageId === messageId) {
return false;
}
if (activity.part.type === 'tool') {
return shouldShowTool(activity.part);
}
return (activity.kind !== 'reasoning' || showReasoningTraces) && !isEmptyTextPart(activity.part);
}));
const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => {
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
@@ -1809,6 +1931,7 @@ const AssistantMessageBody = React.memo(({
let i = 0;
while (i < visibleParts.length) {
const part = visibleParts[i];
rendered = splitLiveActivity && part.type !== 'text' ? activityRendered : answerRendered;
if (part.type === 'text') {
const activity = activityByPart.get(part);
@@ -1820,6 +1943,16 @@ const AssistantMessageBody = React.memo(({
i += 1;
continue;
}
if (isFinalLiveAnswer && !hasRenderedAnswerText && (rendered.length > 0 || activityRendered.length > 0 || hasEarlierVisibleActivity || turnGroupingContext?.hasEarlierAssistantText)) {
rendered.push(
<div
key={`final-answer-divider-${messageId}`}
aria-hidden="true"
className="mt-1.5 mb-3 h-px w-full bg-muted-foreground/20"
/>
);
}
hasRenderedAnswerText = true;
rendered.push(
<div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
<AssistantTextPart
@@ -1968,7 +2101,16 @@ const AssistantMessageBody = React.memo(({
});
});
return rendered;
if (splitLiveActivity && liveFinalActivity) {
return [
<LiveActivityCollapse key="final-message-activity" expanded={liveFinalActivity.expanded}
id={liveFinalActivity.contentId} animateOnMount={liveFinalActivity.animateCollapse}>
{activityRendered}
</LiveActivityCollapse>,
...answerRendered,
];
}
return answerRendered;
}, [
activityByPart,
activityGroupSegmentsForMessage,
@@ -1982,6 +2124,9 @@ const AssistantMessageBody = React.memo(({
isMobile,
isActivityOwnerMessage,
isSortedRenderMode,
liveFinalActivity,
isLastAssistantInTurn,
hasStopFinish,
lastRenderableTextPartIndex,
messageId,
messageActionButtons,
@@ -2019,9 +2164,100 @@ const AssistantMessageBody = React.memo(({
return formatted.length > 0 ? formatted : null;
}, [messageCompletedAt, messageCreatedAt, timeFormatPreference, locale]);
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums';
// Touch surfaces have no hover, so the footer would have to show every
// action at all times — four 36px targets that pushed the metadata onto its
// own lines. Collapse them into one "more" button and a labelled sheet, the
// same one the composer uses to pick a model. The buttons below stay the
// pointer path; these rows call the same handlers, minus the transient
// copied/sharing states that only make sense on a button that stays put.
const [actionSheetOpen, setActionSheetOpen] = React.useState(false);
const footerFactsRef = React.useRef<HTMLDivElement>(null);
useFactsFit(footerFactsRef);
const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
const footerSheetActions = React.useMemo(() => {
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
if (onCopyMessage) {
actions.push({
id: 'copy',
label: t('chat.messageBody.actions.copyAnswer'),
icon: <Icon name="file-copy" className="h-4 w-4" />,
disabled: !hasCopyableText,
// The sheet closes on tap, so the button's own "copied" tick has
// nowhere to land — say it with a toast instead.
onSelect: () => {
void (async () => {
const copied = await onCopyMessage();
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
})();
},
});
}
if (reviewTransferAction && !isMiniChatSurface) {
actions.push({
id: 'review-transfer',
label: reviewTransferAction.tooltip,
icon: <Icon name="arrow-left-right" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText,
onSelect: () => { void reviewTransferAction.onClick(); },
});
}
if (!isMiniChatSurface) {
actions.push({
id: 'share-image',
label: t('chat.messageBody.actions.saveAsImage'),
icon: <Icon name="image-download" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText,
onSelect: () => { void shareMessageAsImage(); },
});
}
if (!isMiniChatSurface && showMessageTTSButtons && hasCopyableText) {
actions.push({
id: 'tts',
label: isFooterTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud'),
icon: <Icon name={isFooterTTSPlaying ? 'stop' : 'volume-up'} className="h-4 w-4" />,
onSelect: () => {
if (isFooterTTSPlaying) {
stopFooterTTS();
return;
}
if (assistantPlanText.trim()) void playFooterTTS(assistantPlanText);
},
});
}
if (canUseProjectPlanActions && !isReviewSessionView) {
actions.push({
id: 'save-as-plan',
label: t('chat.messageBody.actions.saveAsPlan'),
icon: <Icon name="booklet" className="h-3.5 w-3.5" />,
disabled: !hasCopyableText || !currentProjectRef,
onSelect: () => { handleSaveAsPlanClick(); },
});
}
if (onToggleContextPin && hasCopyableText) {
actions.push({
id: 'pin-context',
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
disabled: contextPinPending,
onSelect: () => { onToggleContextPin(); },
});
}
if (!isMiniChatSurface && !isReviewSessionView) {
actions.push({
id: 'fork',
label: t('chat.messageBody.actions.startNewSession'),
icon: <Icon name="chat-new" className="h-3.5 w-3.5" />,
onSelect: () => { handleForkClick(); },
});
}
return actions;
}, [assistantPlanText, canUseProjectPlanActions, contextPinPending, contextPinned, currentProjectRef, handleForkClick, handleSaveAsPlanClick, hasCopyableText, isFooterTTSPlaying, isMiniChatSurface, isReviewSessionView, onCopyMessage, onToggleContextPin, playFooterTTS, reviewTransferAction, shareMessageAsImage, showMessageTTSButtons, stopFooterTTS, t]);
const finalTurnActionButtons = (
<>
{canOpenMessagePreview && messagePreviewUrl ? (
@@ -2031,7 +2267,7 @@ const AssistantMessageBody = React.memo(({
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.openPreviewAria')}
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
@@ -2043,7 +2279,7 @@ const AssistantMessageBody = React.memo(({
openContextPreview(directory, messagePreviewUrl);
}}
>
<Icon name="global" className="h-4 w-4" />
<Icon name="global" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
@@ -2058,13 +2294,13 @@ const AssistantMessageBody = React.memo(({
variant="ghost"
disabled={!hasCopyableText || !currentProjectRef}
className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
(!hasCopyableText || !currentProjectRef) && 'opacity-50'
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleSaveAsPlanClick}
>
<Icon name="booklet" className="h-4 w-4" />
<Icon name="booklet" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
@@ -2078,7 +2314,7 @@ const AssistantMessageBody = React.memo(({
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
)}
disabled={contextPinPending}
@@ -2087,7 +2323,7 @@ const AssistantMessageBody = React.memo(({
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3.5 w-3.5" />
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
@@ -2099,11 +2335,11 @@ const AssistantMessageBody = React.memo(({
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<Icon name="chat-new" className="h-4 w-4" />
<Icon name="chat-new" className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
@@ -2115,11 +2351,11 @@ const AssistantMessageBody = React.memo(({
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkMultiRunClick}
>
<ArrowsMerge className="h-4 w-4" />
<ArrowsMerge className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
@@ -2199,93 +2435,139 @@ const AssistantMessageBody = React.memo(({
)}
{shouldShowTurnFooter && (
<div
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-x-3 gap-y-1.5"
className="mt-2 mb-1 flex flex-col gap-y-1.5"
style={MESSAGE_FOOTER_CONTAINER_STYLE}
>
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
{footerModelName ? (
<span className="flex min-w-0 items-center gap-1.5">
{footerHasLogo && footerLogoSrc ? (
<img
src={footerLogoSrc}
alt=""
className="h-3.5 w-3.5 flex-shrink-0"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleFooterLogoError}
/>
) : (
<Icon
name="brain-ai-3"
className="h-3.5 w-3.5 flex-shrink-0"
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
/>
)}
<span className="truncate">{footerModelName}</span>
</span>
) : null}
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
<span className="flex items-center gap-1">
<Icon name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">
<div className="flex items-center justify-between gap-2">
{/* One line, always. The facts are ordered by how much they
matter, and the CSS drops them from the tail as the row
narrows: first the time, then the agent, then the thinking
effort. Model and duration never leave the model only
truncates once those two alone stop fitting. */}
<div ref={footerFactsRef} className="message-footer__facts whitespace-nowrap text-sm text-muted-foreground/60">
{footerModelName ? (
<span className="flex min-w-0 shrink items-center gap-1.5">
{footerHasLogo && footerLogoSrc ? (
<img
src={footerLogoSrc}
alt=""
className="h-3.5 w-3.5 flex-shrink-0"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleFooterLogoError}
/>
) : (
<Icon
name="brain-ai-3"
className="h-3.5 w-3.5 flex-shrink-0"
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
/>
)}
<span data-fact-model className="truncate">{footerModelName}</span>
</span>
) : null}
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
<span data-fact-priority="3" className="message-footer__fact">
<span className="opacity-60" aria-hidden>·</span>
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
</span>
</span>
) : null}
{footerAgentName ? (
<span className="flex items-center gap-1">
<Icon name="ai-agent" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="message-footer__label">{footerAgentName}</span>
</span>
) : null}
{turnDurationText ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<Icon name="hourglass" className="h-3.5 w-3.5" />
<span className="message-footer__label">{turnDurationText}</span>
</span>
</TooltipTrigger>
<TooltipContent>{turnDurationText}</TooltipContent>
</Tooltip>
) : null}
{footerTimestamp ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={footerTimestampClassName}
aria-label={`Message time: ${footerTimestamp}`}
>
<Icon name="time" className="h-3.5 w-3.5" />
<span className="message-footer__label">{footerTimestamp}</span>
</span>
</TooltipTrigger>
<TooltipContent>{footerTimestamp}</TooltipContent>
</Tooltip>
) : null}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
) : null}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<TurnChangedFilePills
files={turnGroupingContext?.changedFiles}
isInteractive={turnGroupingContext?.isLatestTurn === true}
/>
) : null}
) : null}
{footerAgentName ? (
<span data-fact-priority="2" className="message-footer__fact">
<span className="opacity-60" aria-hidden>·</span>
{footerAgentName}
</span>
) : null}
{turnDurationText ? (
<span className="message-footer__fact tabular-nums">
{footerModelName ? <span className="opacity-60" aria-hidden>·</span> : null}
{turnDurationText}
</span>
) : null}
{/* Pointer surfaces keep the timestamp inline (it is the first
fact the row gives up); touch reads it in the actions sheet,
where nothing can push it off the row. */}
{footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? (
<span
data-fact-priority="1"
className={cn(footerTimestampClassName, 'message-footer__fact')}
aria-label={`Message time: ${footerTimestamp}`}
>
<span className="opacity-60" aria-hidden>·</span>
{footerTimestamp}
</span>
) : null}
</div>
<div
className={cn(
'flex items-center gap-1.5',
alwaysShowMessageActions || isTouchContext
? undefined
: 'pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100'
)}
data-message-action-group="true"
{alwaysShowMessageActions || isTouchContext ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('chat.messageBody.actions.moreActions')}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setActionSheetOpen(true);
}}
data-message-action-group="true"
>
<Icon name="more" className="h-3.5 w-3.5" />
</Button>
) : (
<div
className="flex shrink-0 items-center gap-1.5 pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100 [&_button]:!h-[26px] [&_button]:!w-[26px] [&_svg]:!size-3.5"
data-message-action-group="true"
>
{messageActionButtons}
{finalTurnActionButtons}
</div>
)}
</div>
{/* Changed files keep their own line: they are a list that
grows, not a fact about the run. */}
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
<TurnChangedFilePills
files={turnGroupingContext?.changedFiles}
isInteractive={turnGroupingContext?.isLatestTurn === true}
/>
</div>
) : null}
<MobileOverlayPanel
open={actionSheetOpen}
onClose={() => setActionSheetOpen(false)}
title={t('chat.messageBody.actions.moreActions')}
>
{messageActionButtons}
{finalTurnActionButtons}
</div>
<div className="flex flex-col">
{/* The row drops the timestamp first on a narrow screen,
so the sheet is where it is always readable. */}
{footerTimestamp ? (
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
<Icon name="time" className="h-4 w-4" />
<span className="typography-ui-label">{footerTimestamp}</span>
</div>
) : null}
{footerSheetActions.map((action) => (
<button
key={action.id}
type="button"
disabled={action.disabled}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => {
setActionSheetOpen(false);
action.onSelect();
}}
style={{ touchAction: 'manipulation' }}
>
<span className="text-muted-foreground">{action.icon}</span>
<span className="typography-ui-label">{action.label}</span>
</button>
))}
</div>
</MobileOverlayPanel>
</div>
)}
@@ -118,6 +118,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const requestBtwComposer = useInputStore((state) => state.requestBtwComposer);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
@@ -469,6 +470,19 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
addMarkdownToChat(selectedTextMarkdown);
}, [addMarkdownToChat, selectedTextMarkdown]);
const handleAskOpenChamber = React.useCallback(() => {
if (!currentSessionId || !selectedTextMarkdown) return;
requestBtwComposer({
parentSessionId: currentSessionId,
text: wrapMarkdownSelectionForChat(selectedTextMarkdown),
});
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [currentSessionId, hideMenu, requestBtwComposer, selectedTextMarkdown]);
const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return;
setCommentMode(true);
@@ -705,6 +719,24 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button>
{currentSessionId ? (
<button
onClick={handleAskOpenChamber}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.askOpenChamber')}
type="button"
>
<Icon name="chat-ai-3" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.askOpenChamber')}</span>
</button>
) : null}
{!isVSCodeRuntime() ? (
<button
onClick={handleAddToNotes}
@@ -766,6 +798,26 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
{t('chat.textSelection.actions.comment')}
</button>
{currentSessionId ? (
<>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleAskOpenChamber}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.askOpenChamber')}
type="button"
>
{t('chat.textSelection.actions.askOpenChamber')}
</button>
</>
) : null}
{!isVSCodeRuntime() ? (
<>
@@ -46,11 +46,78 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- `ReasoningPart.tsx`
- Thinking block UI (`ReasoningTimelineBlock`), summary + optional duration.
- `components/LiveTurnActivity.tsx` (relative to the chat folder)
- Owns the optional live-only turn disclosure. `MessageList` enables it when
Activity Default is Collapsed and the turn has visible Activity content.
- `components/LiveActivityCollapse.tsx` owns the finite height transition;
`components/liveActivityContext.ts` scopes the final message's non-text
disclosure without changing sorted message context or tool rendering.
- `lib/turns/liveActivity.ts` owns final-answer and interruption boundaries.
- `lib/turns/liveActivitySummary.ts` derives the report from tool results.
- `JustificationBlock.tsx`
- Justification block wrapper over `ReasoningTimelineBlock`.
## Current important behavior
### Optional live history disclosure
Activity Default is shared by the settings UI in both render modes. In live
mode, Expanded preserves the original timeline without a turn disclosure.
Collapsed adds one Activity header after completion or interruption while preserving the original live rows,
their order, and their individual controls. It adds no tool subgroups, side
line, height cap, or inner scroller. Sorted rendering keeps its existing path
and its own per-turn expansion state.
The active turn stays open without an Activity header. A final assistant message with `finish: stop`
collapses the earlier messages and the final message's non-text parts, keeping
the answer and its existing footer outside. Intermediate-text summary fallback
and compaction summaries never become final answers. An older turn without a
final answer collapses once a later visible turn has an assistant response;
a queued user message alone is not enough. Hidden user continuations retain
the visible-turn mapping established by `projectTurnRecords`.
Manual expansion survives later metadata updates and timeline virtualization
within the session. The disclosure uses a finite 180ms height transition,
respects reduced motion, and delegates end pinning to the existing timeline.
It never calls scroll-to-bottom. Collapsed history does not mount its hidden
message bodies; initial history loads do not animate collapse.
Layout-effect replay after a Suspense hide/reveal must settle the requested
height and retained children even when the expanded target did not change.
Cleanup stops the animation, so a same-target early return can leave a cached
pre-collapse height on the DOM indefinitely. Failed animations also settle;
callbacks from cancelled, superseded animations never settle a newer target.
The virtualizer also adds temporary end padding while compensating prepended
history. The Bun patch for `@legendapp/list@3.3.10` stores that padding's CSSOM
read-back value: Chromium rounds fractional pixel strings, so comparing the
original input with `style.paddingBottom` can skip cleanup permanently. This
leaves a phantom tail even when every Activity region is already zero-height.
The patch covers both web entry points in ESM and CJS; its installed-controller
regression tests live in `scripts/legend-list-padding.test.mjs`. Retain this
fix when updating the dependency unless upstream has equivalent ownership and
cleanup behavior. Chat padding and scroll policies do not compensate for it.
The header retains its report when expanded and has no hover background. Its
left inset matches sorted Activity. Diff deletions use the ASCII hyphen.
The header reports five categories: changed files, codebase
exploration, commands, web research, and subagents. Narrow chat columns only
show file changes. Exploration and research are flags, not synthetic counts.
Subagents count distinct child session IDs; commands count calls, not shell
subcommands. Unknown tools and administrative tools stay in the disclosure
without a guessed summary category.
File statistics come exclusively from successful edit/write/patch tool
results, not user-message summary diffs or the current workspace Git diff.
Unique normalized paths determine file count; renames preserve identities.
Line totals sum performed edits, including lines later removed by another
call. Per-file patches/counts take precedence over a whole-call patch; the two
representations are never added together. Missing or truncated diffs suppress
the line total rather than presenting a partial total as complete. Write input
content is not evidence of added lines. Repeated records of one call count once.
### Message parts
- 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
@@ -943,7 +943,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
if (!showHeader) {
return (
<FadeInOnReveal>
<div className="mt-1 mb-2 space-y-1.5">{renderedRows}</div>
<div className="mt-1 mb-2">{renderedRows}</div>
</FadeInOnReveal>
);
}
@@ -986,7 +986,10 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
+{previewHiddenCount} more...
</button>
) : null}
<div className="space-y-1.5">{renderedRows}</div>
{/* No gap between rows: each row carries its own padding, and
the live timeline stacks the same rows with nothing between
them, so the sorted view keeps the identical rhythm. */}
<div>{renderedRows}</div>
</div>
) : null}
</div>
@@ -1,14 +1,55 @@
import React, { act } from 'react';
import { describe, expect, test } from 'bun:test';
import { plugin } from 'bun';
import { pathToFileURL } from 'node:url';
import { renderToStaticMarkup } from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import type { Part } from '@opencode-ai/sdk/v2';
import { createOpencodeClient, type Part } from '@opencode-ai/sdk/v2';
import { SyncProvider } from '@/sync/sync-context';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { I18nProvider } from '@/lib/i18n';
import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart';
import type { StreamPhase } from '../types';
// Bun does not implement Vite's asset-query imports. Preserve the real asset
// URL while keeping the renderer and worker client modules unchanged.
plugin({
name: 'reasoning-worker-url',
setup(build) {
build.onLoad({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, ({ path }) => ({
contents: `export default ${JSON.stringify(pathToFileURL(path.split('?')[0]).href)};`,
loader: 'js',
}));
},
});
const unavailable = (): never => { throw new Error('Reasoning scrolling must not call runtime APIs'); };
const runtimeApis: RuntimeAPIs = {
runtime: { platform: 'web', isDesktop: false, isVSCode: false },
get terminal() { return unavailable(); },
get git() { return unavailable(); },
get files() { return unavailable(); },
get settings() { return unavailable(); },
get permissions() { return unavailable(); },
get notifications() { return unavailable(); },
get tools() { return unavailable(); },
};
const sdk = createOpencodeClient({
baseUrl: 'http://localhost',
fetch: async () => new Response('[]', { headers: { 'Content-Type': 'application/json' } }),
});
const TestProviders = ({ children }: { children: React.ReactNode }) => (
<RuntimeAPIContext.Provider value={runtimeApis}>
<SyncProvider sdk={sdk} directory="">
<I18nProvider>{children}</I18nProvider>
</SyncProvider>
</RuntimeAPIContext.Provider>
);
type ReasoningPartFixture = Extract<Part, { type: 'reasoning' }>;
/**
@@ -22,9 +63,18 @@ const DOM_GLOBAL_NAMES = [
'window',
'document',
'navigator',
'localStorage',
'customElements',
'Node',
'NodeList',
'Element',
'HTMLElement',
'SVGElement',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
'ResizeObserver',
'MutationObserver',
'IS_REACT_ACT_ENVIRONMENT',
] as const;
@@ -33,13 +83,40 @@ const installDomStub = () => {
const previous = DOM_GLOBAL_NAMES.map(
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
);
const observers: ResizeObserverStub[] = [];
class ResizeObserverStub implements ResizeObserver {
readonly targets = new Set<Element>();
disconnectCount = 0;
constructor(private readonly callback: ResizeObserverCallback) {
observers.push(this);
}
observe(target: Element) { this.targets.add(target); }
unobserve(target: Element) { this.targets.delete(target); }
disconnect() {
this.disconnectCount += 1;
this.targets.clear();
}
notify() {
if (this.targets.size > 0) this.callback([], this);
}
}
const values = {
window: happyWindow,
document: happyWindow.document,
navigator: happyWindow.navigator,
localStorage: happyWindow.localStorage,
customElements: happyWindow.customElements,
Node: happyWindow.Node,
NodeList: happyWindow.NodeList,
Element: happyWindow.Element,
HTMLElement: happyWindow.HTMLElement,
SVGElement: happyWindow.SVGElement,
requestAnimationFrame: happyWindow.requestAnimationFrame.bind(happyWindow),
cancelAnimationFrame: happyWindow.cancelAnimationFrame.bind(happyWindow),
getComputedStyle: happyWindow.getComputedStyle.bind(happyWindow),
ResizeObserver: ResizeObserverStub,
MutationObserver: happyWindow.MutationObserver,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const name of DOM_GLOBAL_NAMES) {
@@ -53,6 +130,7 @@ const installDomStub = () => {
return {
container,
observers,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
@@ -78,14 +156,14 @@ const LONG_JUSTIFICATION =
describe('ReasoningTimelineBlock', () => {
test('renders reasoning traces behind an accessible collapsed disclosure by default', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningTimelineBlock
text={LONG_REASONING}
variant="thinking"
blockId="reasoning-test"
showDuration={false}
/>
</I18nProvider>,
</TestProviders>,
);
// Accessible toggle row is rendered
@@ -103,7 +181,7 @@ describe('ReasoningTimelineBlock', () => {
test('renders "Justification" label for justification variant when pre-expanded and not streaming', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningTimelineBlock
text={LONG_JUSTIFICATION}
variant="justification"
@@ -111,7 +189,7 @@ describe('ReasoningTimelineBlock', () => {
showDuration={false}
defaultExpanded={true}
/>
</I18nProvider>,
</TestProviders>,
);
// Label shown in expanded header should be "Justification" not "Thinking"
@@ -121,7 +199,7 @@ describe('ReasoningTimelineBlock', () => {
test('renders "Thinking" label for thinking variant when pre-expanded and not streaming', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningTimelineBlock
text={LONG_REASONING}
variant="thinking"
@@ -129,7 +207,7 @@ describe('ReasoningTimelineBlock', () => {
showDuration={false}
defaultExpanded={true}
/>
</I18nProvider>,
</TestProviders>,
);
// Label shown in expanded header should be "Thinking"
@@ -138,14 +216,14 @@ describe('ReasoningTimelineBlock', () => {
test('header summary is a truncated excerpt from the beginning', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningTimelineBlock
text={LONG_REASONING}
variant="thinking"
blockId="reasoning-test"
showDuration={false}
/>
</I18nProvider>,
</TestProviders>,
);
// Deep body content beyond 120 chars should be cut from the summary span
@@ -156,14 +234,14 @@ describe('ReasoningTimelineBlock', () => {
test('omits trailing empty HTML comments from the header summary', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningTimelineBlock
text={'Planning accessible icon labels with translations <!-- -->'}
variant="thinking"
blockId="reasoning-comment-test"
showDuration={false}
/>
</I18nProvider>,
</TestProviders>,
);
expect(markup).toContain('Planning accessible icon labels with translations');
@@ -198,9 +276,9 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
// reachable and the issue reproduces.
const renderPart = (part: ReasoningPartFixture, streamPhase?: StreamPhase): string =>
renderToStaticMarkup(
<I18nProvider>
<TestProviders>
<ReasoningPart part={part} messageId="msg_2020" streamPhase={streamPhase} />
</I18nProvider>,
</TestProviders>,
);
test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => {
@@ -239,6 +317,16 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
expect(markup).toContain('aria-expanded="true"');
});
test('streaming reasoning stays inside the capped nested scroll box', () => {
// The box is capped while streaming too, so a long thought scrolls inside
// its own box instead of growing the timeline; it is marked as a nested
// scroller so an upward wheel over it scrolls the box before the chat.
const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming');
expect(markup).toContain('max-h-80');
expect(markup).toContain('data-scrollable="true"');
});
test('a live part with no committed text yet shows the busy header and no empty summary', () => {
// The streaming early-return keeps the block mounted before the block-level
// reveal commits a first line. The header must read as busy and must not
@@ -266,7 +354,7 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
const renderTree = () =>
React.createElement(
I18nProvider,
TestProviders,
null,
React.createElement(ReasoningPart, { part, messageId: 'msg_2020', streamPhase: undefined }),
);
@@ -296,3 +384,66 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
}
});
});
describe('ReasoningTimelineBlock live follow', () => {
test('scrollbar scrolling releases live follow and returning to the bottom resumes it', async () => {
const dom = installDomStub();
const root = createRoot(dom.container);
const renderBlock = (isStreaming: boolean) => (
<TestProviders>
<ReasoningTimelineBlock
text="Working through the task step by step."
variant="thinking"
blockId="reasoning-follow"
defaultExpanded
isStreaming={isStreaming}
showDuration={false}
/>
</TestProviders>
);
try {
await act(async () => { root.render(renderBlock(true)); });
const scroller = dom.container.querySelector<HTMLElement>('[data-scrollable="true"]');
if (!scroller) throw new Error('Expected the mounted reasoning scroll box');
const body = scroller.firstElementChild;
const followObserver = dom.observers.find((observer) => observer.targets.size === 1 && body && observer.targets.has(body));
if (!followObserver) throw new Error('Expected an observer of the reasoning body');
let contentHeight = 800;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 320 },
scrollHeight: { configurable: true, get: () => contentHeight },
});
await act(async () => { followObserver.notify(); });
expect(scroller.scrollTop).toBe(480);
// A scrollbar drag emits scroll, without a wheel or touch event.
await act(async () => {
scroller.scrollTop = 120;
scroller.dispatchEvent(new window.Event('scroll'));
});
contentHeight = 1000;
await act(async () => { followObserver.notify(); });
expect(scroller.scrollTop).toBe(120);
await act(async () => {
scroller.scrollTop = 680;
scroller.dispatchEvent(new window.Event('scroll'));
});
contentHeight = 1200;
await act(async () => { followObserver.notify(); });
expect(scroller.scrollTop).toBe(880);
await act(async () => { root.render(renderBlock(false)); });
expect(followObserver.disconnectCount).toBe(1);
expect(followObserver.targets.size).toBe(0);
contentHeight = 1400;
await act(async () => { followObserver.notify(); });
expect(scroller.scrollTop).toBe(880);
} finally {
await act(async () => { root.unmount(); });
dom.restore();
}
});
});
@@ -122,6 +122,59 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false);
// The thinking body lives in a capped scroll box in every state. While it
// streams, the box follows its own end so the newest thought stays in
// view without growing the timeline; a wheel or drag upward inside the
// box hands the box to the reader, and returning to its end re-arms the
// follow. The chat's own end-follow is unaffected: the box keeps a fixed
// height once capped, so the timeline stops growing underneath it, and an
// upward wheel over the box scrolls the box first (it is a nested
// scroller) and only reaches the chat once the box sits at its top.
const scrollBoxRef = React.useRef<HTMLElement | null>(null);
const followBoxEndRef = React.useRef(true);
const touchStartYRef = React.useRef<number | null>(null);
const releaseBoxFollow = React.useCallback(() => {
followBoxEndRef.current = false;
}, []);
const handleBoxWheel = React.useCallback((event: React.WheelEvent<HTMLElement>) => {
if (event.deltaY < 0) releaseBoxFollow();
}, [releaseBoxFollow]);
const handleBoxTouchStart = React.useCallback((event: React.TouchEvent<HTMLElement>) => {
touchStartYRef.current = event.touches[0]?.clientY ?? null;
}, []);
const handleBoxTouchMove = React.useCallback((event: React.TouchEvent<HTMLElement>) => {
const startY = touchStartYRef.current;
const touch = event.touches[0];
if (startY === null || !touch) return;
// A downward finger drags the content up: the reader wants history.
if (touch.clientY > startY + 4) releaseBoxFollow();
}, [releaseBoxFollow]);
const handleBoxScroll = React.useCallback((event: React.UIEvent<HTMLElement>) => {
const node = event.currentTarget;
const distanceToEnd = node.scrollHeight - node.clientHeight - node.scrollTop;
followBoxEndRef.current = distanceToEnd <= 2;
}, []);
React.useEffect(() => {
if (!isStreaming) return;
followBoxEndRef.current = true;
const node = scrollBoxRef.current;
if (!node || !globalThis.ResizeObserver) return;
const content = node.firstElementChild;
if (!content) return;
const follow = () => {
if (!followBoxEndRef.current) return;
const end = node.scrollHeight - node.clientHeight;
if (end - node.scrollTop > 1) node.scrollTop = end;
};
// Growth lands asynchronously (markdown commits off the render pass),
// so the content box is observed rather than the text prop.
const observer = new ResizeObserver(follow);
observer.observe(content);
follow();
return () => observer.disconnect();
}, [isStreaming, shouldRenderExpandedContent]);
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded
? t('chat.reasoningTrace.collapseAria')
@@ -389,28 +442,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
style={{ backgroundColor: 'var(--tools-border)' }}
/>
{isStreaming ? (
// While streaming, let the thinking grow inline — no
// capped, independently-scrollable box. The chat's own
// auto-follow then handles following / releasing, so the
// box never captures the wheel or fights the user's
// scroll. The max-height scroll box is applied only once
// the thinking has finished (the branch below).
<div className="p-0">
{reasoningBody}
</div>
) : (
<ScrollableOverlay
as="div"
outerClassName="max-h-80"
className="p-0"
useScrollShadow
scrollShadowSize={36}
userIntentOnly
>
{reasoningBody}
</ScrollableOverlay>
)}
<ScrollableOverlay
ref={scrollBoxRef}
as="div"
outerClassName="max-h-80"
className="p-0"
useScrollShadow
scrollShadowSize={36}
userIntentOnly
data-scrollable="true"
onWheel={handleBoxWheel}
onTouchStart={handleBoxTouchStart}
onTouchMove={handleBoxTouchMove}
onScroll={handleBoxScroll}
>
<div>{reasoningBody}</div>
</ScrollableOverlay>
</div>
</div>
) : null}
@@ -907,7 +907,7 @@ const TaskSummaryEntryRow = React.memo(({
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons, 'typography-meta')
) : (
status === 'error' ? (
<span className={cn(
@@ -958,7 +958,7 @@ const TaskSummaryEntriesList = React.memo(({
const visibleStartIndex = entries.length - visibleEntries.length;
return (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} className="pt-0" disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
@@ -1154,7 +1154,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
);
};
const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true) => {
const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true, textClassName = TOOL_ROW_DESCRIPTION_CLASS) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
@@ -1163,7 +1163,7 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<Text
variant={animate ? 'generate-effect' : 'static'}
className={cn('min-w-0 truncate whitespace-nowrap', TOOL_ROW_DESCRIPTION_CLASS, grow && 'flex-1')}
className={cn('min-w-0 truncate whitespace-nowrap', textClassName, grow && 'flex-1')}
style={{ color: 'var(--tools-title)' }}
>
{path}
@@ -1180,7 +1180,7 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
return (
<span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}>
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<span className={cn('min-w-0 inline-flex max-w-full items-baseline overflow-hidden', TOOL_ROW_DESCRIPTION_CLASS, grow && 'flex-1')}>
<span className={cn('min-w-0 inline-flex max-w-full items-baseline overflow-hidden', textClassName, grow && 'flex-1')}>
{hasAbsoluteRoot ? <span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span> : null}
<span
className="min-w-0 shrink truncate whitespace-nowrap"
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { TurnGroupingContext } from '../lib/turns/types';
import { areRelevantTurnGroupingContextsEqual } from './renderCompare';
const finalAnswerContext: TurnGroupingContext = {
turnId: 'turn',
isFirstAssistantInTurn: false,
isLastAssistantInTurn: true,
isLatestTurn: true,
isWorking: false,
hasTools: false,
hasReasoning: false,
hasEarlierAssistantText: false,
};
describe('final answer divider context', () => {
test('updates the final answer when earlier visible text appears or disappears', () => {
const withEarlierText = { ...finalAnswerContext, hasEarlierAssistantText: true };
expect(areRelevantTurnGroupingContextsEqual(finalAnswerContext, withEarlierText, 'answer', false)).toBe(false);
expect(areRelevantTurnGroupingContextsEqual(withEarlierText, finalAnswerContext, 'answer', false)).toBe(false);
});
test('preserves equivalent rebuilt context', () => {
expect(areRelevantTurnGroupingContextsEqual(finalAnswerContext, { ...finalAnswerContext }, 'answer', false)).toBe(true);
});
test('does not invalidate the user message for assistant decoration', () => {
expect(areRelevantTurnGroupingContextsEqual(
finalAnswerContext,
{ ...finalAnswerContext, hasEarlierAssistantText: true },
'user',
true,
)).toBe(true);
});
});
@@ -291,6 +291,7 @@ export const areRelevantTurnGroupingContextsEqual = (
if (left.turnId !== right.turnId) return false;
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
if (left.hasEarlierAssistantText !== right.hasEarlierAssistantText) return false;
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
if (left.isLatestTurn !== right.isLatestTurn) return false;
if (left.isWorking !== right.isWorking) return false;
@@ -0,0 +1,76 @@
import React from 'react';
/**
* Keeps the assistant footer's facts on one line by dropping the least
* important ones until the rest fit.
*
* The row itself never overflows the model name truncates instead so "did
* it fit" is read off the model, and facts marked `data-fact-priority` are
* hidden in that order (1 goes first) until the model is whole again. That is
* the rule the design asks for: the timestamp goes, then the agent, then the
* thinking effort, and only a row with nothing left to give truncates the
* model.
*
* CSS alone cannot do this. Hiding on container-width breakpoints guesses at
* the model's length and drops facts that would have fitted, and wrapping the
* overflow onto a clipped second line leaves the dropped fact's width behind as
* a hole in the middle of the row.
*
* The measuring is deliberately blunt: a handful of layout reads after each
* render of a row that exists once per turn, and only for the turns on screen.
*/
export const useFactsFit = (ref: React.RefObject<HTMLElement | null>): void => {
const applyRef = React.useRef<() => void>(() => {});
applyRef.current = () => {
const container = ref.current;
if (!container) return;
const model = container.querySelector<HTMLElement>('[data-fact-model]');
if (!model) return;
const facts = Array.from(container.querySelectorAll<HTMLElement>('[data-fact-priority]'))
.sort((left, right) => Number(left.dataset.factPriority) - Number(right.dataset.factPriority));
for (const fact of facts) fact.style.display = '';
const modelFits = () => model.scrollWidth <= model.clientWidth + 1;
for (const fact of facts) {
if (modelFits()) return;
fact.style.display = 'none';
}
};
// After every render: the facts change while a turn finishes (the duration
// keeps counting), and that changes what fits without changing any box the
// observer below watches.
React.useLayoutEffect(() => {
applyRef.current();
});
React.useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
let inCallback = false;
const refit = () => {
// Hiding a fact never resizes the row (its width comes from the layout
// above it), but guard the re-entry anyway.
if (inCallback) return;
inCallback = true;
applyRef.current();
inCallback = false;
};
// The window covers the common cases (a desktop window resized, a phone
// rotated); the observer covers the ones that leave the window alone —
// a sidebar opening, a panel dragged wider.
window.addEventListener('resize', refit);
const observer = new ResizeObserver(refit);
observer.observe(container);
return () => {
window.removeEventListener('resize', refit);
observer.disconnect();
};
}, [ref]);
};
@@ -6,7 +6,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getProjectDraftStarters, saveProjectDraftStarters } from '@/lib/openchamberConfig';
import { getProjectDraftStarters, saveProjectDraftStarters, updateSharedProjectSetup, type ProjectDraftStarter } from '@/lib/openchamberConfig';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { IconName } from '@/components/icon/icons';
import {
@@ -31,6 +31,8 @@ export type ResolvedStarter = {
label: string;
icon: IconName;
submitText: string;
/** Pinned by the team in the repo's shared config; not removable here. */
shared: boolean;
};
export type PinnableSection = 'built-in' | 'command' | 'skill';
@@ -55,6 +57,10 @@ export type UseDraftStartersResult = {
addStarter: (item: PinnableItem) => void;
removeStarter: (group: StarterGroup, ref: DraftStarterRef) => void;
reorder: (group: StarterGroup, fromId: string, toId: string) => void;
/** Move one of the user's project starters into the repo's shared file. */
shareStarter: (ref: DraftStarterRef) => void;
/** Move a shared project starter back into the user's own list. */
unshareStarter: (ref: DraftStarterRef) => void;
};
export function useDraftStarters(): UseDraftStartersResult {
@@ -73,7 +79,13 @@ export function useDraftStarters(): UseDraftStartersResult {
return { id: found.id, path: found.path };
}, [activeProjectId, projects]);
const [projectStarters, setProjectStarters] = React.useState<DraftStarterRef[]>([]);
// Merged: the team's shared starters first, then the user's own. Only the
// personal ones are ever written back.
const [projectStarters, setProjectStarters] = React.useState<ProjectDraftStarter[]>([]);
const personalProjectStarters = React.useMemo<DraftStarterRef[]>(
() => projectStarters.filter((r) => r.source === 'personal').map(({ type, name }) => ({ type, name })),
[projectStarters],
);
React.useEffect(() => {
let cancelled = false;
@@ -106,18 +118,19 @@ export function useDraftStarters(): UseDraftStartersResult {
const commandNames = React.useMemo(() => new Set(commands.map((c) => c.name)), [commands]);
const skillNames = React.useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
const resolve = React.useCallback((ref: DraftStarterRef, group: StarterGroup): ResolvedStarter | null => {
const resolve = React.useCallback((ref: DraftStarterRef, group: StarterGroup, shared = false): ResolvedStarter | null => {
if (isVSCode && ref.type === 'command' && (ref.name === 'craft-goal' || ref.name === 'schedule-task')) return null;
const plain: DraftStarterRef = { type: ref.type, name: ref.name };
if (ref.type === 'command') {
const builtin = getBuiltInStarter(ref.name);
if (builtin) {
return { id: chipId(group, ref), ref, group, label: t(builtin.labelKey), icon: builtin.icon, submitText: builtin.command };
return { id: chipId(group, plain), ref: plain, group, label: t(builtin.labelKey), icon: builtin.icon, submitText: builtin.command, shared };
}
if (!commandNames.has(ref.name)) return null;
return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: COMMAND_FALLBACK_ICON, submitText: `/${ref.name}` };
return { id: chipId(group, plain), ref: plain, group, label: normalizeStarterLabel(ref.name), icon: COMMAND_FALLBACK_ICON, submitText: `/${ref.name}`, shared };
}
if (!skillNames.has(ref.name)) return null;
return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: SKILL_FALLBACK_ICON, submitText: `/${ref.name}` };
return { id: chipId(group, plain), ref: plain, group, label: normalizeStarterLabel(ref.name), icon: SKILL_FALLBACK_ICON, submitText: `/${ref.name}`, shared };
}, [t, commandNames, skillNames, isVSCode]);
const globalRefs = React.useMemo<readonly DraftStarterRef[]>(
@@ -130,7 +143,7 @@ export function useDraftStarters(): UseDraftStartersResult {
[globalRefs, resolve],
);
const project = React.useMemo(
() => projectStarters.map((r) => resolve(r, 'project')).filter((x): x is ResolvedStarter => x !== null),
() => projectStarters.map((r) => resolve(r, 'project', r.source === 'shared')).filter((x): x is ResolvedStarter => x !== null),
[projectStarters, resolve],
);
@@ -160,11 +173,22 @@ export function useDraftStarters(): UseDraftStartersResult {
const persistGlobal = React.useCallback((next: DraftStarterRef[]) => {
useUIStore.getState().setGlobalDraftStarters(next);
void updateDesktopSettings({ draftStarters: next });
// The markers make a deliberate removal of a built-in starter durable:
// without them the load path re-inserts Craft a Goal / Schedule a Task.
// They travel with the user's edit, never with a bootstrap.
void updateDesktopSettings({
draftStarters: next,
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
});
}, []);
// `next` is the user's own list; the shared ones stay in front, untouched.
const persistProject = React.useCallback((next: DraftStarterRef[]) => {
setProjectStarters(next);
setProjectStarters((current) => [
...current.filter((r) => r.source === 'shared'),
...next.map((r) => ({ ...r, source: 'personal' as const })),
]);
if (projectRef) void saveProjectDraftStarters(projectRef, next);
}, [projectRef]);
@@ -172,31 +196,63 @@ export function useDraftStarters(): UseDraftStartersResult {
const ref: DraftStarterRef = { type: item.type, name: item.name };
if (item.scope === 'project') {
if (!projectRef || projectStarters.some((r) => sameStarter(r, ref))) return;
persistProject([...projectStarters, ref]);
persistProject([...personalProjectStarters, ref]);
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
if (base.some((r) => sameStarter(r, ref))) return;
persistGlobal([...base, ref]);
}
}, [projectRef, projectStarters, globalRaw, persistProject, persistGlobal]);
}, [projectRef, projectStarters, personalProjectStarters, globalRaw, persistProject, persistGlobal]);
const removeStarter = React.useCallback((group: StarterGroup, ref: DraftStarterRef) => {
if (group === 'project') {
persistProject(projectStarters.filter((r) => !sameStarter(r, ref)));
// A shared starter is the team's; it leaves only through the repo file.
if (!personalProjectStarters.some((r) => sameStarter(r, ref))) return;
persistProject(personalProjectStarters.filter((r) => !sameStarter(r, ref)));
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
persistGlobal(base.filter((r) => !sameStarter(r, ref)));
}
}, [projectStarters, globalRaw, persistProject, persistGlobal]);
}, [personalProjectStarters, globalRaw, persistProject, persistGlobal]);
// Sharing moves a starter between the two files: into the repo file first,
// then out of the personal list; the merged list is reloaded from the server.
const reloadProjectStarters = React.useCallback(() => {
if (!projectRef) return;
void getProjectDraftStarters(projectRef).then(setProjectStarters).catch(() => undefined);
}, [projectRef]);
const shareStarter = React.useCallback((ref: DraftStarterRef) => {
if (!projectRef) return;
const shared = projectStarters.filter((r) => r.source === 'shared').map(({ type, name }) => ({ type, name }));
if (shared.some((r) => sameStarter(r, ref))) return;
void (async () => {
if (!(await updateSharedProjectSetup(projectRef, { draftStarters: [...shared, ref] }))) return;
await saveProjectDraftStarters(projectRef, personalProjectStarters.filter((r) => !sameStarter(r, ref)));
reloadProjectStarters();
})();
}, [personalProjectStarters, projectRef, projectStarters, reloadProjectStarters]);
const unshareStarter = React.useCallback((ref: DraftStarterRef) => {
if (!projectRef) return;
const shared = projectStarters.filter((r) => r.source === 'shared').map(({ type, name }) => ({ type, name }));
if (!shared.some((r) => sameStarter(r, ref))) return;
void (async () => {
if (!(await updateSharedProjectSetup(projectRef, { draftStarters: shared.filter((r) => !sameStarter(r, ref)) }))) return;
await saveProjectDraftStarters(projectRef, [...personalProjectStarters.filter((r) => !sameStarter(r, ref)), ref]);
reloadProjectStarters();
})();
}, [personalProjectStarters, projectRef, projectStarters, reloadProjectStarters]);
const reorder = React.useCallback((group: StarterGroup, fromId: string, toId: string) => {
const base = group === 'project' ? projectStarters : (globalRaw ?? DEFAULT_GLOBAL_STARTERS);
// Project chips reorder among the user's own; shared ones keep their place in front.
const base = group === 'project' ? personalProjectStarters : (globalRaw ?? DEFAULT_GLOBAL_STARTERS);
const from = base.findIndex((r) => chipId(group, r) === fromId);
const to = base.findIndex((r) => chipId(group, r) === toId);
if (from < 0 || to < 0 || from === to) return;
const next = arrayMove([...base], from, to);
if (group === 'project') persistProject(next); else persistGlobal(next);
}, [projectStarters, globalRaw, persistProject, persistGlobal]);
}, [personalProjectStarters, globalRaw, persistProject, persistGlobal]);
return { global, project, pinnable, hasProject: !!projectRef, ensureLoaded, addStarter, removeStarter, reorder };
return { global, project, pinnable, hasProject: !!projectRef, ensureLoaded, addStarter, removeStarter, reorder, shareStarter, unshareStarter };
}
@@ -17,7 +17,8 @@ conditionally; passing "am I first?" down would mean each one tracking what the
sections above it decided to render.
Sections render nothing when they have no rows, so the panel collapses upward
instead of reserving empty space.
instead of reserving empty space. Turn stats keeps its header for a
selected session even without metrics, so a saved collapsed state can reopen.
## What it is not
@@ -103,11 +104,49 @@ which requests only providers enabled for this panel.
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
| Turn stats | `telemetry.ts` over `useSessionMessageRecords` | computed only while expanded and authoritatively idle |
| Goal | `useSessionGoal` | respects the Settings toggle |
| MCP | `useMcpStore` | connect/disconnect reuses the dropdown's actions |
| Pinned messages | `getContextObligatoryMessages` + `state.part` | see below |
| Todos | live `state.todo[sessionId]`, persisted fallback | live channel wins |
### Turn stats
The section follows Usage and reuses the panel's existing rows. Only its header
has an icon; metric rows use labels and values without leading icons. It
reads already-loaded records without fetching history. The newest turn needs a
preceding user message and completed assistant steps. A truncated or unfinished
turn has no whole-turn result; later materialization can supply it.
Two rates answer different questions. Response speed uses the final assistant
message's output tokens divided by the union of its nonempty text intervals.
It excludes initial waiting, reasoning tokens and reasoning time, and earlier
tool steps. It requires complete, valid text timing and a final message without
tools, errors, synthetic text or ignored text. This measures text delivery from
stored timestamps, not provider-side decode speed. The header shows only this
rate; unavailable response timing never falls back to whole-turn speed.
Whole-turn speed uses output plus reasoning tokens from every step, divided by
elapsed assistant time minus the union of completed and failed tool intervals.
Waiting for each model response remains included. Invalid or missing inputs
omit the dependent metric rather than becoming zero; reported zeros remain
valid. TTFT averages the earliest text/reasoning start delay from every step,
only when all steps have a valid sample.
Metric labels stay short. Every row is a single hover and keyboard-focus target
for a shared tooltip, with a 750ms hover delay and a portal outside the panel's
scroller. Tooltips explain the measurement in every locale. The token row uses
compact input/output arrows; its tooltip gives full counts and explains that
input excludes cached tokens and output includes reasoning across all steps.
Records subscriptions and aggregation stop while collapsed, busy, retrying, or
awaiting status authority. Explicit idle events or a successful directory status
snapshot allow computation. One component-owned committed result keeps the
headline and rows stable during the next active turn. Its identity includes
runtime, normalized directory and session. Scope changes discard it; fresh empty
or reverted records clear it. There is no global message-ID cache. Corrections
to existing message/part identities invalidate the current result.
### Context usage has its own computation, on purpose
`useSessionUIStore.getContextUsage` cannot serve this panel for two reasons:
@@ -191,8 +230,9 @@ the row reflects the reset tree rather than a mid-creation snapshot.
Ordering is by durability, not category:
1. **Session** (goal, context, cost), **Project** (attention, branch,
changes, PR, checks) and **Usage** — true for as long as the session is
open. Usage sits here rather than lower down because a spent quota stops the
changes, PR, checks), **Usage**, and **Turn stats** (session telemetry:
throughput, duration, TTFT, cache hit rate) — true for as long as the session
is open. Usage sits here rather than lower down because a spent quota stops the
work outright;
2. **Subagents**, **Tasks** — what is happening right now;
3. **MCP**, **Pinned messages**, **Context sources** — supporting material.
@@ -201,10 +241,13 @@ Ordering is by durability, not category:
A persisted preference (`workStatusPanelEnabled`) drives a header toggle, and a
dialog behind the equalizer icon switches individual sections off. Hidden
sections are stored rather than visible ones, so a section added later appears
for everyone instead of staying invisible to whoever had saved settings before
it existed. Both travel the full settings pipeline, including the server
whitelist without which the keys never reach `settings.json`.
sections are stored rather than visible ones. Every section, including Turn
stats, is enabled by default. UI-store v21 migration and server-list hydration
remove the old automatic telemetry hiding unless `workStatusHiddenSectionsExplicit`
records a user-chosen list. Explicit hiding and other hidden sections survive.
The marker and list travel together through autosave, sanitization, and server
settings; an empty list enables everything. Complete settings
snapshots own this preference; unrelated partial save echoes leave it unchanged.
`workStatusPanelVisible` is separate and transient: the switch can be on while
layout still refuses the panel. The header and the git rail read it to drop the
@@ -8,6 +8,7 @@ import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup';
import { WorkStatusUsageSection } from './WorkStatusUsageSection';
import { WorkStatusTelemetrySection } from './WorkStatusTelemetrySection';
import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection';
import { WorkStatusTasksSection } from './WorkStatusTasksSection';
import { WorkStatusMcpSection } from './WorkStatusMcpSection';
@@ -254,6 +255,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
/>
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
{sectionVisible('telemetry') ? <WorkStatusTelemetrySection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('subagents') ? <WorkStatusSubagentsSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('tasks') ? <WorkStatusTasksSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('mcp') ? <WorkStatusMcpSection directory={directory} /> : null}
@@ -20,9 +20,8 @@ import {
/**
* Which sections the work-status panel may show.
*
* Everything is on by default and the choice is stored as the *hidden* set, so
* a section added in a later release appears for everyone rather than staying
* invisible to whoever had saved settings before it existed.
* Choices are stored as the hidden set. Telemetry is opt-in; Show all is an
* explicit choice to enable it along with the other sections.
*/
export const WorkStatusSectionsDialog: React.FC<{
open: boolean;
@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { createOpencodeClient, type AssistantMessage, type Session, type UserMessage } from '@opencode-ai/sdk/v2';
import { useUIStore } from '@/stores/useUIStore';
import { I18nProvider } from '@/lib/i18n';
import { SyncProvider } from '@/sync/sync-context';
import { getSyncChildStores } from '@/sync/sync-refs';
import { getSyncPerformanceDiagnostics, resetSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from '@/sync/performance-diagnostics';
let WorkStatusTelemetrySection: typeof import('./WorkStatusTelemetrySection').WorkStatusTelemetrySection;
const directory = '/repo';
const sessionId = 'session-1';
const user: UserMessage = { id: 'user-1', sessionID: sessionId, role: 'user', time: { created: 1000 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } };
const session: Session = { id: sessionId, slug: 'test', projectID: 'project', directory, title: 'test', version: '1', time: { created: 0, updated: 1 } };
let tokenReads = 0;
const assistant: AssistantMessage = {
id: 'assistant-final', sessionID: sessionId, role: 'assistant', parentID: user.id,
agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: directory, root: directory },
time: { created: 2000, completed: 7000 }, cost: 0.01,
get tokens() { tokenReads += 1; return { input: 100, output: 20, reasoning: 10, cache: { read: 40, write: 0 } }; },
};
const DOM_GLOBAL_NAMES = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLIFrameElement', 'localStorage', 'getComputedStyle', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT'] as const;
const installDom = () => {
const win = new Window({ url: 'http://localhost' });
const previous = DOM_GLOBAL_NAMES.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
const values = { window: win, document: win.document, navigator: win.navigator, Node: win.Node, Element: win.Element,
HTMLElement: win.HTMLElement, HTMLIFrameElement: win.HTMLIFrameElement, localStorage: win.localStorage,
getComputedStyle: win.getComputedStyle.bind(win), ResizeObserver: win.ResizeObserver,
requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win), IS_REACT_ACT_ENVIRONMENT: true };
for (const name of DOM_GLOBAL_NAMES) Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
const container = document.createElement('div');
document.body.appendChild(container);
return { container, restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
void win.happyDOM.close();
} };
};
describe('mounted turn telemetry with live sync stores', () => {
let root: Root;
let dom: ReturnType<typeof installDom>;
let messageRequests = 0;
// Keep bootstrap pending so each test controls real store publications. No
// hook/module replacements: subscription and materialization paths are real.
const sdk = createOpencodeClient({ baseUrl: 'http://telemetry.test', fetch: (request) => {
const url = new URL(request instanceof Request ? request.url : request.toString());
if (/\/session\/[^/]+\/message$/.test(url.pathname)) messageRequests += 1;
return new Promise<Response>(() => undefined);
} });
const render = async (visible = true, selectedDirectory = directory, selectedSession = sessionId) => {
await act(async () => root.render(
<SyncProvider sdk={sdk} directory={selectedDirectory}>
<I18nProvider>{visible ? <WorkStatusTelemetrySection sessionId={selectedSession} directory={selectedDirectory} /> : null}</I18nProvider>
</SyncProvider>,
));
};
const store = (dir = directory) => {
const result = getSyncChildStores().getChild(dir);
if (!result) throw new Error('Expected mounted directory store');
return result;
};
beforeEach(async () => {
dom = installDom();
({ WorkStatusTelemetrySection } = await import('./WorkStatusTelemetrySection'));
root = createRoot(dom.container);
tokenReads = 0;
messageRequests = 0;
setSyncPerformanceDiagnosticsEnabled(true);
useUIStore.setState({ workStatusExpandedSections: {}, workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false });
await render();
await act(async () => store().setState({ session: [session], message: { [sessionId]: [user, assistant] }, part: { [assistant.id]: [] }, session_status: {} }));
});
afterEach(async () => {
await act(async () => root.unmount());
setSyncPerformanceDiagnosticsEnabled(false);
dom.restore();
});
test('waits for authority, then shows actual token values even when idle is omitted from the snapshot', async () => {
expect(dom.container.textContent).toContain('Turn stats');
expect(dom.container.textContent).not.toContain('Whole turn');
await act(async () => store().setState({ sessionStatusReady: true }));
expect(dom.container.textContent).toContain('~6 tok/s');
expect(dom.container.textContent).toContain('100 ↑ · 30 ↓');
const heading = dom.container.querySelector('button');
if (!heading) throw new Error('Expected section heading');
expect(heading.textContent).toBe('Turn stats');
expect(heading.querySelectorAll('svg').length).toBe(2);
expect(dom.container.querySelectorAll('svg').length).toBe(2);
expect(tokenReads > 0).toBe(true);
expect(messageRequests).toBe(0);
});
test('collapsed remount keeps a usable header and reopening reads fresh data', async () => {
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
const button = dom.container.querySelector('button');
if (!button) throw new Error('Expected collapse button');
await act(async () => button.click());
await render(false);
await render();
expect(dom.container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false');
expect(dom.container.textContent).toContain('Turn stats');
expect(dom.container.textContent).not.toContain('Whole turn');
const reopen = dom.container.querySelector('button');
if (!reopen) throw new Error('Expected reopen button');
await act(async () => reopen.click());
expect(dom.container.textContent).toContain('~6 tok/s');
});
test('busy, retry and collapsed updates do not notify records subscribers or aggregate tokens', async () => {
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
// Positive control: on idle, part replacement reaches the subscriber and calculator.
tokenReads = 0;
resetSyncPerformanceDiagnostics();
await act(async () => store().setState({ part: { [assistant.id]: [] } }));
expect(tokenReads > 0).toBe(true);
expect((getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks ?? 0) > 0).toBe(true);
for (const mode of ['busy', 'retry', 'collapsed'] as const) {
await act(async () => {
store().setState({ session_status: { [sessionId]: mode === 'retry'
? { type: 'retry', attempt: 1, message: 'retry', next: 0 }
: { type: mode === 'busy' ? 'busy' : 'idle' } } });
useUIStore.getState().setWorkStatusSectionExpanded('telemetry', mode !== 'collapsed');
});
resetSyncPerformanceDiagnostics();
tokenReads = 0;
for (let i = 0; i < 100; i += 1) {
await act(async () => store().setState({ part: { [assistant.id]: [{ id: 'text', sessionID: sessionId,
messageID: assistant.id, type: 'text', text: String(i), time: { start: 2500 } }] } }));
}
expect(tokenReads).toBe(0);
expect(getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks).toBe(0);
if (mode === 'collapsed') expect(dom.container.textContent).toBe('Turn stats');
else expect(dom.container.textContent).toContain('~6 tok/s');
}
});
test('session and directory changes cannot retain another scope, including equal IDs', async () => {
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
expect(dom.container.textContent).toContain('~6 tok/s');
await render(true, directory, 'another-session');
expect(dom.container.textContent).not.toContain('~6 tok/s');
await render(true, '/another-repo');
await act(async () => store('/another-repo').setState({ session_status: { [sessionId]: { type: 'busy' } } }));
expect(dom.container.textContent).not.toContain('~6 tok/s');
await render(true, directory);
expect(dom.container.textContent).toContain('~6 tok/s');
});
test('same-ID corrections, partial history and reverts replace rather than cache stale stats', async () => {
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } }, message: { [sessionId]: [assistant] } }));
expect(dom.container.textContent).not.toContain('Whole turn');
await act(async () => store().setState({ message: { [sessionId]: [user, assistant] } }));
expect(dom.container.textContent).toContain('~6 tok/s');
const corrected = { ...assistant, tokens: { ...assistant.tokens, output: 90 } };
await act(async () => store().setState({ message: { [sessionId]: [user, corrected] } }));
expect(dom.container.textContent).toContain('~20 tok/s');
await act(async () => store().setState({ session: [{ ...session, revert: { messageID: user.id } }] }));
expect(dom.container.textContent).not.toContain('~20 tok/s');
await act(async () => store().setState({ session: [session] }));
expect(dom.container.textContent).toContain('~20 tok/s');
await act(async () => store().setState({ message: {} }));
expect(dom.container.textContent).not.toContain('~20 tok/s');
});
test('runtime identity changes discard retained results even with equal directory and session IDs', async () => {
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'busy' } } }));
expect(dom.container.textContent).toContain('~6 tok/s');
Object.defineProperty(window, '__OPENCHAMBER_API_BASE_URL__', { value: 'https://second-runtime.test', configurable: true });
await render();
expect(dom.container.textContent).not.toContain('~6 tok/s');
});
test('the heading shows response speed only, never whole-turn speed as a fallback', async () => {
await act(async () => store().setState({ sessionStatusReady: true, part: { [assistant.id]: [
{ id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } },
] } }));
expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats~10 tok/s');
expect(dom.container.textContent).toContain('Response~10 tok/s');
expect(dom.container.textContent).toContain('Whole turn~6 tok/s');
await act(async () => store().setState({ part: { [assistant.id]: [] } }));
expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats');
expect(dom.container.textContent).not.toContain('Response');
expect(dom.container.textContent).toContain('Whole turn~6 tok/s');
});
test('every metric has a full-row focus target and hover waits 750ms', async () => {
const earlier = { ...assistant, id: 'earlier', time: { created: 1100, completed: 1900 } };
await act(async () => store().setState({ sessionStatusReady: true,
message: { [sessionId]: [user, earlier, assistant] }, part: {
earlier: [{ id: 'earlier-text', type: 'text', sessionID: sessionId, messageID: earlier.id, text: 'Earlier', time: { start: 1200, end: 1800 } }],
[assistant.id]: [{ id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } }],
},
}));
const triggers = dom.container.querySelectorAll<HTMLElement>('[data-slot="tooltip-trigger"]');
expect(triggers.length).toBe(9);
for (const trigger of triggers) expect(trigger.tabIndex).toBe(0);
expect(dom.container.querySelectorAll('[title]').length).toBe(0);
const response = triggers[0];
await act(async () => {
response.dispatchEvent(new window.PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }));
response.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true }));
response.dispatchEvent(new window.MouseEvent('mouseenter', { bubbles: true }));
response.dispatchEvent(new window.MouseEvent('mousemove', { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 650));
});
expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
expect(response.hasAttribute('data-popup-open')).toBe(false);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); });
expect(response.hasAttribute('data-popup-open')).toBe(true);
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('How fast the final text arrived');
expect(dom.container.querySelector('[data-slot="tooltip-content"]')).toBeNull();
});
test('keyboard focus exposes the cost explanation without adding an icon or native title', async () => {
await act(async () => store().setState({ sessionStatusReady: true }));
const triggers = dom.container.querySelectorAll<HTMLElement>('[data-slot="tooltip-trigger"]');
const cost = triggers[triggers.length - 1];
await act(async () => cost.focus());
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('Cost reported by the provider');
expect(cost.querySelector('svg')).toBeNull();
expect(cost.hasAttribute('title')).toBe(false);
});
});
@@ -0,0 +1,185 @@
import React from 'react';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDirectorySync, useSessionMessageRecords, useSyncDirectory, useSyncRuntime } from '@/sync/sync-context';
import { normalizePath } from '@/lib/pathNormalization';
import { useUIStore } from '@/stores/useUIStore';
import {
WorkStatusCollapsibleSection,
WorkStatusRow,
WorkStatusValue,
} from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import {
formatTelemetryDuration,
formatTelemetryTokens,
formatThroughputRate,
getLatestCompletedTurnStats,
type CompletedTurnStats,
} from './telemetry';
type Props = {
sessionId: string | null;
directory: string | null;
};
/** One hover/focus target covers both the label and its value. */
const TelemetryRow: React.FC<{ label: string; description: string; value: React.ReactNode }> = ({ label, description, value }) => (
<Tooltip delayDuration={750}>
<TooltipTrigger asChild>
<div tabIndex={0} className="min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring">
<WorkStatusRow label={label} value={value} />
</div>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8} className="max-w-[min(320px,calc(100vw-24px))] whitespace-normal break-words text-left">
<p className="font-medium">{label}</p>
<p>{description}</p>
</TooltipContent>
</Tooltip>
);
export const WorkStatusTelemetrySection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const expanded = useUIStore(
React.useCallback((state) => state.workStatusExpandedSections['telemetry'] ?? true, []),
);
const { runtimeKey } = useSyncRuntime();
const syncDirectory = useSyncDirectory();
const scope = JSON.stringify([runtimeKey, normalizePath(directory ?? syncDirectory), sessionId]);
const status = useDirectorySync(
React.useCallback((state) => sessionId
? state.session_status[sessionId]?.type ?? (state.sessionStatusReady ? 'idle' : 'unknown')
: 'unknown', [sessionId]),
directory ?? undefined,
);
const eligibleForStats = Boolean(sessionId && expanded && status === 'idle');
const records = useSessionMessageRecords(
sessionId ?? '',
directory ?? undefined,
{ enabled: eligibleForStats },
);
const computed = React.useMemo(() => {
if (!eligibleForStats) return null;
return getLatestCompletedTurnStats(records);
}, [eligibleForStats, records]);
// Retain only one committed result, never message history or a global ID cache.
const [retained, setRetained] = React.useState<{ scope: string; stats: CompletedTurnStats | null } | null>(null);
React.useEffect(() => {
if (eligibleForStats) {
setRetained({ scope, stats: computed });
} else {
setRetained((previous) => previous?.scope === scope && status !== 'unknown' ? previous : null);
}
}, [scope, status, eligibleForStats, computed]);
const stats = eligibleForStats ? computed : status !== 'unknown' && retained?.scope === scope ? retained.stats : null;
const summary = stats && stats.responseTokensPerSecond !== null
? formatThroughputRate(stats.responseTokensPerSecond)
: undefined;
useReportWorkStatusPresence('telemetry', Boolean(sessionId));
if (!sessionId) return null;
return (
<WorkStatusCollapsibleSection
id="telemetry"
title={t('chat.workStatus.section.telemetry')}
icon="bar-chart-2"
summary={summary}
defaultExpanded
>
{stats ? (
<>
{stats.responseTokensPerSecond !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.responseSpeed')}
description={t('chat.workStatus.telemetry.responseSpeedDescription')}
value={<WorkStatusValue>{formatThroughputRate(stats.responseTokensPerSecond)}</WorkStatusValue>}
/>
) : null}
{stats.tokensPerSecond !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.speed')}
description={t('chat.workStatus.telemetry.speedDescription')}
value={<WorkStatusValue>{formatThroughputRate(stats.tokensPerSecond)}</WorkStatusValue>}
/>
) : null}
{stats.totalLlmDurationMs !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.llmDuration')}
description={t('chat.workStatus.telemetry.llmDurationDescription')}
value={<WorkStatusValue>{formatTelemetryDuration(stats.totalLlmDurationMs)}</WorkStatusValue>}
/>
) : null}
{stats.totalToolDurationMs !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.toolDuration')}
description={t('chat.workStatus.telemetry.toolDurationDescription')}
value={<WorkStatusValue>{formatTelemetryDuration(stats.totalToolDurationMs)}</WorkStatusValue>}
/>
) : null}
{stats.avgTtftMs !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.ttft')}
description={t('chat.workStatus.telemetry.ttftDescription')}
value={<WorkStatusValue>{formatTelemetryDuration(stats.avgTtftMs)}</WorkStatusValue>}
/>
) : null}
{stats.stepsCount > 1 ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.steps')}
description={t('chat.workStatus.telemetry.stepsDescription')}
value={<WorkStatusValue>{stats.stepsCount}</WorkStatusValue>}
/>
) : null}
{stats.inputTokens !== null && stats.outputTokens !== null && stats.reasoningTokens !== null && stats.totalGeneratedTokens !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.tokens')}
description={t('chat.workStatus.telemetry.tokensDescription', {
input: stats.inputTokens.toLocaleString(getCurrentIntlLocale()),
output: stats.outputTokens.toLocaleString(getCurrentIntlLocale()),
reasoning: stats.reasoningTokens.toLocaleString(getCurrentIntlLocale()),
})}
value={(
<WorkStatusValue>
{t('chat.workStatus.telemetry.tokens.inOut', {
input: formatTelemetryTokens(stats.inputTokens),
output: formatTelemetryTokens(stats.totalGeneratedTokens),
})}
</WorkStatusValue>
)}
/>
) : null}
{stats.cacheHitPercent !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.cacheHit')}
description={t('chat.workStatus.telemetry.cacheHitDescription')}
value={(
<WorkStatusValue tone={stats.cacheHitPercent >= 50 ? 'success' : 'default'}>
{`${stats.cacheHitPercent}%`}
</WorkStatusValue>
)}
/>
) : null}
{stats.cost !== null ? (
<TelemetryRow
label={t('chat.workStatus.telemetry.cost')}
description={t('chat.workStatus.telemetry.costDescription')}
value={<WorkStatusValue tone="muted">{`$${stats.cost.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}`}</WorkStatusValue>}
/>
) : null}
</>
) : null}
</WorkStatusCollapsibleSection>
);
};
@@ -111,9 +111,19 @@ describe('sanitizeWorkStatusHiddenSections', () => {
expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']);
});
test('treats a non-array payload as no preference', () => {
test('treats a non-array payload as default hidden preference', () => {
expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]);
expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]);
expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]);
});
test('removes only the old implicit telemetry default', () => {
expect(sanitizeWorkStatusHiddenSections(['mcp', 'telemetry'], false)).toEqual(['mcp']);
expect(sanitizeWorkStatusHiddenSections([], false)).toEqual([]);
});
test('preserves explicit hiding, including hiding every section', () => {
expect(sanitizeWorkStatusHiddenSections(['mcp', 'telemetry'], true)).toEqual(['mcp', 'telemetry']);
expect(sanitizeWorkStatusHiddenSections([...WORK_STATUS_SECTION_IDS], true)).toEqual([...WORK_STATUS_SECTION_IDS]);
});
});
@@ -14,6 +14,7 @@ export const WORK_STATUS_SECTION_IDS = [
'session',
'repository',
'usage',
'telemetry',
'subagents',
'tasks',
'mcp',
@@ -23,16 +24,17 @@ export const WORK_STATUS_SECTION_IDS = [
type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number];
export const WORK_STATUS_SECTION_LABEL_KEYS: Record<WorkStatusSectionId, I18nKey> = {
export const WORK_STATUS_SECTION_LABEL_KEYS = {
session: 'chat.workStatus.section.session',
repository: 'chat.workStatus.section.project',
usage: 'chat.workStatus.section.usage',
telemetry: 'chat.workStatus.section.telemetry',
subagents: 'chat.workStatus.section.subagents',
tasks: 'chat.workStatus.section.tasks',
mcp: 'chat.workStatus.section.mcp',
pinned: 'chat.workStatus.section.pinned',
contextSources: 'chat.workStatus.section.contextBreakdown',
};
} as const satisfies Record<WorkStatusSectionId, I18nKey>;
const KNOWN_IDS = new Set<string>(WORK_STATUS_SECTION_IDS);
@@ -40,9 +42,7 @@ const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId =>
typeof value === 'string' && KNOWN_IDS.has(value);
/**
* Hidden sections are stored, not visible ones: everything is on by default, so
* an empty list means "the user has changed nothing" and a section added later
* appears without touching anyone's saved settings.
* Hidden sections are stored, not visible ones. Every section is on by default.
*/
export const isWorkStatusSectionVisible = (
hidden: readonly string[] | null | undefined,
@@ -75,11 +75,13 @@ export const getWorkStatusPanelPresentation = ({
showEmptyState: contentMounted && allSectionsHidden,
});
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
export const sanitizeWorkStatusHiddenSections = (value: unknown, explicit = true): WorkStatusSectionId[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<WorkStatusSectionId>();
for (const entry of value) {
if (isWorkStatusSectionId(entry)) seen.add(entry);
}
// Older clients hid telemetry automatically until the user chose a list.
if (!explicit) seen.delete('telemetry');
return [...seen];
};
@@ -0,0 +1,204 @@
import { describe, expect, test } from 'bun:test';
import type { AssistantMessage, Part, TextPart, UserMessage } from '@opencode-ai/sdk/v2';
import { formatTelemetryDuration, formatTelemetryTokens, formatThroughputRate, getLatestCompletedTurnStats, mergeTimeIntervals, sumIntervalsDuration } from './telemetry';
const user: UserMessage = { id: 'u1', sessionID: 'session-1', role: 'user', time: { created: 0 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } };
const assistant = (overrides: Partial<AssistantMessage> = {}): AssistantMessage => ({
id: 'a1', sessionID: 'session-1', role: 'assistant', parentID: user.id,
agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: '/repo', root: '/repo' },
time: { created: 1000, completed: 5000 }, cost: 0,
tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
...overrides,
});
const tool = (start: number, end: number): Part => ({
id: `tool-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'call',
state: { status: 'completed', input: {}, output: '', title: 'test', metadata: {}, time: { start, end } },
});
const text = (start: number): TextPart => ({ id: `text-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'text', text: '', time: { start } });
const turn = (info = assistant(), parts: Part[] = []) => [{ info: user, parts: [] }, { info, parts }];
describe('turn telemetry', () => {
test('merges unsorted parallel, nested, adjoining and invalid tool intervals', () => {
expect(mergeTimeIntervals([])).toEqual([]);
const merged = mergeTimeIntervals([[3000, 4000], [1000, 3000], [1500, 2500], [6000, 7000], [NaN, 1], [9, 8]]);
expect(merged).toEqual([[1000, 4000], [6000, 7000]]);
expect(sumIntervalsDuration(merged)).toBe(4000);
});
test('formats durations, counts and approximate throughput', () => {
expect(formatTelemetryDuration(0)).toBe('0.0s');
expect(formatTelemetryDuration(1234)).toBe('1.2s');
expect(formatTelemetryDuration(84000)).toBe('1m24s');
expect(formatTelemetryTokens(0)).toBe('0');
expect(formatTelemetryTokens(500)).toBe('500');
expect(formatTelemetryTokens(1234)).toBe('1.2K');
expect(formatTelemetryTokens(1500000)).toBe('1.5M');
expect(formatThroughputRate(52.3)).toBe('~52 tok/s');
});
test('aggregates a multi-step turn, subtracting the tool union and including reasoning tokens', () => {
const records = turn(assistant({
time: { created: 10000, completed: 20000 }, cost: 0.01,
tokens: { input: 1000, output: 200, reasoning: 300, cache: { read: 2000, write: 0 } },
}), [text(11500), tool(13000, 15000), tool(14000, 16000)]);
records.push({ info: assistant({ id: 'a2', time: { created: 21000, completed: 24000 }, cost: 0.005,
tokens: { input: 1500, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
}), parts: [text(21500)] });
const stats = getLatestCompletedTurnStats(records);
expect(stats).toEqual({ stepsCount: 2, lastAssistantMessageId: 'a2', totalToolDurationMs: 3000,
totalLlmDurationMs: 10000, outputTokens: 300, reasoningTokens: 300, totalGeneratedTokens: 600,
inputTokens: 2500, cost: 0.015, tokensPerSecond: 60, responseTokensPerSecond: null, avgTtftMs: 1000, cacheHitPercent: 44 });
});
test('uses only the latest user-bounded turn', () => {
const records = [...turn(), ...turn(assistant({ id: 'new' }))];
expect(getLatestCompletedTurnStats(records)?.stepsCount).toBe(1);
expect(getLatestCompletedTurnStats(records)?.lastAssistantMessageId).toBe('new');
});
test('does not publish unfinished or truncated turns, or substitute older results', () => {
expect(getLatestCompletedTurnStats(null)).toBeNull();
expect(getLatestCompletedTurnStats([])).toBeNull();
expect(getLatestCompletedTurnStats([{ info: assistant(), parts: [] }])).toBeNull();
expect(getLatestCompletedTurnStats([...turn(), { info: user, parts: [] }])).toBeNull();
expect(getLatestCompletedTurnStats([...turn(), ...turn(assistant({ time: { created: 1000 } }))])).toBeNull();
expect(getLatestCompletedTurnStats([
...turn(assistant({ time: { created: 1000 } })), { info: assistant({ id: 'a2' }), parts: [] },
])).toBeNull();
});
test('recomputes after history materializes and after same-ID message or part corrections', () => {
const info = assistant();
expect(getLatestCompletedTurnStats([{ info, parts: [] }])).toBeNull();
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25);
expect(getLatestCompletedTurnStats(turn({ ...info, tokens: { ...info.tokens, output: 200 } }))?.tokensPerSecond).toBe(50);
expect(getLatestCompletedTurnStats(turn(info, [tool(2000, 4000)]))?.tokensPerSecond).toBe(50);
// A second directory/runtime may reuse IDs but must never reuse the result.
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25);
});
test('missing usage in one step invalidates whole-turn usage, not valid durations', () => {
const missing = assistant({ id: 'a2', time: { created: 5000, completed: 6000 } });
Reflect.deleteProperty(missing, 'tokens');
Reflect.deleteProperty(missing, 'cost');
const stats = getLatestCompletedTurnStats([...turn(), { info: missing, parts: [] }]);
expect(stats?.stepsCount).toBe(2);
expect(stats?.totalLlmDurationMs).toBe(5000);
expect(stats?.tokensPerSecond).toBeNull();
expect(stats?.inputTokens).toBeNull();
expect(stats?.cost).toBeNull();
});
test('missing reasoning is not treated as zero and invalid token counts are not summed', () => {
const info = assistant();
Reflect.deleteProperty(info.tokens, 'reasoning');
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBeNull();
for (const output of [-1, NaN, Infinity]) {
expect(getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output } })))?.totalGeneratedTokens).toBeNull();
}
});
test('preserves genuine zero usage, cache hits and cost', () => {
const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 0 } })));
expect(stats?.tokensPerSecond).toBe(0);
expect(stats?.cost).toBe(0);
expect(stats?.cacheHitPercent).toBe(0);
});
test('includes failed tools and chooses the earliest text or reasoning timestamp', () => {
const failed: Part = { id: 'failed', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'failed',
state: { status: 'error', input: {}, error: 'failed', time: { start: 2500, end: 4000 } } };
const reasoning: Part = { id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: '', time: { start: 1200 } };
const stats = getLatestCompletedTurnStats(turn(assistant(), [text(1600), reasoning, tool(2000, 3000), failed]));
expect(stats?.totalToolDurationMs).toBe(2000);
expect(stats?.totalLlmDurationMs).toBe(2000);
expect(stats?.avgTtftMs).toBe(200);
});
for (const [start, end] of [[0, 2000], [2000, 6000], [3000, 2000], [NaN, 3000]]) {
test(`invalid tool interval ${start}..${end} omits duration-dependent metrics`, () => {
const stats = getLatestCompletedTurnStats(turn(assistant(), [tool(start, end)]));
expect(stats?.totalToolDurationMs).toBeNull();
expect(stats?.totalLlmDurationMs).toBeNull();
expect(stats?.tokensPerSecond).toBeNull();
expect(stats?.outputTokens).toBe(100);
});
}
test('unfinished tools and missing tool timing cannot produce a rate', () => {
const unfinished: Part = { id: 'pending', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'pending',
state: { status: 'pending', input: {}, raw: '' } };
const missing = tool(2000, 3000);
if (missing.type !== 'tool') throw new Error('Expected tool fixture');
Reflect.deleteProperty(missing.state, 'time');
expect(getLatestCompletedTurnStats(turn(assistant(), [unfinished]))?.tokensPerSecond).toBeNull();
expect(getLatestCompletedTurnStats(turn(assistant(), [missing]))?.tokensPerSecond).toBeNull();
});
test('invalid step time does not silently remove that step from totals', () => {
const stats = getLatestCompletedTurnStats([...turn(), { info: assistant({ id: 'a2', time: { created: 6000, completed: 5000 } }), parts: [] }]);
expect(stats?.stepsCount).toBe(2);
expect(stats?.totalGeneratedTokens).toBe(200);
expect(stats?.totalLlmDurationMs).toBeNull();
expect(stats?.tokensPerSecond).toBeNull();
});
test('separates final text delivery from whole-turn throughput on the measured tool-heavy shape', () => {
const records = turn(assistant({
time: { created: 1000, completed: 38438 },
tokens: { ...assistant().tokens, output: 223 },
}), [tool(19950, 38438)]);
records.push({ info: assistant({ id: 'final', time: { created: 40000, completed: 45598 },
tokens: { ...assistant().tokens, output: 338 },
}), parts: [{ ...text(42661), text: 'Final answer', time: { start: 42661, end: 45442 } }] });
const stats = getLatestCompletedTurnStats(records);
expect(Math.round(stats?.tokensPerSecond ?? 0)).toBe(23);
expect(Math.round(stats?.responseTokensPerSecond ?? 0)).toBe(122);
});
test('measures the final text only, excluding reasoning tokens and their time', () => {
const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 260, reasoning: 100 } }), [
{ id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: 'Thinking', time: { start: 1200, end: 2000 } },
{ ...text(2500), text: 'Final answer', time: { start: 2500, end: 4500 } },
]));
expect(stats?.responseTokensPerSecond).toBe(130);
expect(stats?.tokensPerSecond).toBe(90);
});
test('unions overlapping text intervals without mutating the authoritative parts', () => {
const parts = [
{ ...text(2000), text: 'First', time: { start: 2000, end: 3500 } },
{ ...text(3000), text: 'Second', time: { start: 3000, end: 4000 } },
];
const stats = getLatestCompletedTurnStats(turn(assistant(), parts));
expect(stats?.responseTokensPerSecond).toBe(50);
expect(parts[0].time.end).toBe(3500);
});
test('missing, partial or invalid response timing never falls back to whole-turn speed', () => {
const invalidParts: Part[][] = [
[], [{ ...text(2000), text: 'No end' }],
[{ ...text(2000), text: 'Bad end', time: { start: 2000, end: 1000 } }],
[{ ...text(2000), text: 'Late end', time: { start: 2000, end: 6000 } }],
[{ ...text(2000), text: 'Zero span', time: { start: 2000, end: 2000 } }],
[{ ...text(2000), text: 'Bad time', time: { start: NaN, end: 4000 } }],
[{ ...text(2000), text: 'Synthetic', synthetic: true, time: { start: 2000, end: 4000 } }],
[{ ...text(2000), text: 'Tool preface', time: { start: 2000, end: 3000 } }, tool(3000, 4000)],
[{ ...text(2000), text: 'Timed', time: { start: 2000, end: 3000 } }, { ...text(3000), text: 'Untimed' }],
];
for (const parts of invalidParts) {
const stats = getLatestCompletedTurnStats(turn(assistant(), parts));
expect(stats?.responseTokensPerSecond).toBeNull();
expect(stats?.tokensPerSecond !== null).toBe(true);
}
});
test('response speed needs valid output usage and a successful final reply', () => {
const parts = [{ ...text(2000), text: 'Final reply', time: { start: 2000, end: 4000 } }];
const missingUsage = assistant();
Reflect.deleteProperty(missingUsage.tokens, 'output');
expect(getLatestCompletedTurnStats(turn(missingUsage, parts))?.responseTokensPerSecond).toBeNull();
expect(getLatestCompletedTurnStats(turn(assistant({ error: { name: 'MessageAbortedError', data: { message: 'Stopped' } } }), parts))?.responseTokensPerSecond).toBeNull();
expect(getLatestCompletedTurnStats(turn(assistant({ time: { created: NaN, completed: 5000 } }), parts))?.responseTokensPerSecond).toBeNull();
});
});
@@ -0,0 +1,291 @@
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
type SessionMessageRecord = {
info: Message;
parts: Part[];
};
type CompletedStepStats = {
toolDurationMs: number | null;
adjustedLlmDurationMs: number | null;
ttftMs: number | null;
inputTokens: number | null;
outputTokens: number | null;
reasoningTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
cost: number | null;
};
export type CompletedTurnStats = {
lastAssistantMessageId: string;
stepsCount: number;
totalLlmDurationMs: number | null;
totalToolDurationMs: number | null;
avgTtftMs: number | null;
tokensPerSecond: number | null;
responseTokensPerSecond: number | null;
inputTokens: number | null;
outputTokens: number | null;
reasoningTokens: number | null;
totalGeneratedTokens: number | null;
cacheHitPercent: number | null;
cost: number | null;
};
/**
* Merge an array of [start, end] time intervals into a disjoint union of intervals.
* Correctly accounts for parallel / overlapping tool executions without double-counting.
*/
export function mergeTimeIntervals(intervals: readonly (readonly [number, number])[]): Array<[number, number]> {
if (intervals.length === 0) return [];
const valid: Array<[number, number]> = [];
for (const [start, end] of intervals) {
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
valid.push([start, end]);
}
}
valid.sort((a, b) => a[0] - b[0]);
if (valid.length === 0) return [];
const merged: Array<[number, number]> = [valid[0]];
for (let i = 1; i < valid.length; i += 1) {
const current = valid[i];
const last = merged[merged.length - 1];
if (current[0] <= last[1]) {
last[1] = Math.max(last[1], current[1]);
} else {
merged.push(current);
}
}
return merged;
}
/**
* Sum the total duration spanned by an array of disjoint intervals.
*/
export function sumIntervalsDuration(intervals: readonly (readonly [number, number])[]): number {
return intervals.reduce((sum, [start, end]) => sum + (end - start), 0);
}
export const formatTelemetryDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) {
return '0.0s';
}
if (ms < 60_000) {
return `${(ms / 1000).toFixed(1)}s`;
}
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m${seconds}s`;
};
export const formatTelemetryTokens = (tokens: number): string => {
if (!Number.isFinite(tokens) || tokens <= 0) {
return '0';
}
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1)}K`;
}
return String(Math.round(tokens));
};
export const formatThroughputRate = (tps: number): string => {
return `~${Math.round(tps)} tok/s`;
};
const nonnegative = (value: number | undefined): number | null =>
value !== undefined && Number.isFinite(value) && value >= 0 ? value : null;
const add = (left: number | null, right: number | null): number | null =>
left === null || right === null ? null : nonnegative(left + right);
/** Text delivery rate for the final reply, not throughput of the agent loop. */
function calculateResponseTokenRate(record: SessionMessageRecord): number | null {
const { info, parts } = record;
if (info.role !== 'assistant' || info.error || parts.some((part) => part.type === 'tool')) return null;
const output = nonnegative(info.tokens?.output);
const { created, completed } = info.time;
if (output === null || completed === undefined || nonnegative(created) === null || nonnegative(completed) === null) return null;
const intervals: Array<[number, number]> = [];
for (const part of parts) {
if (part.type !== 'text') continue;
// Synthetic/ignored text cannot be matched to the provider's output count.
if (part.synthetic || part.ignored) return null;
if (!part.text) continue;
const start = part.time?.start;
const end = part.time?.end;
if (start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end)
|| start < created || end > completed || end <= start) return null;
intervals.push([start, end]);
}
const duration = sumIntervalsDuration(mergeTimeIntervals(intervals));
return duration > 0 ? nonnegative(output / (duration / 1000)) : null;
}
/**
* Calculate stats for a single completed assistant step.
*/
function calculateCompletedStepStats(record: SessionMessageRecord): CompletedStepStats | null {
const { info, parts } = record;
if (info.role !== 'assistant') return null;
const { created } = info.time;
const completed = info.time.completed;
if (completed === undefined) return null;
const validWindow = nonnegative(created) !== null && nonnegative(completed) !== null && completed >= created;
const totalDurationMs = validWindow ? nonnegative(completed - created) : null;
// An unfinished or invalid tool makes duration-dependent metrics unknown.
const rawToolIntervals: Array<[number, number]> = [];
let validTools = validWindow;
for (const part of parts) {
if (part.type !== 'tool') continue;
if (part.state.status !== 'completed' && part.state.status !== 'error') {
validTools = false;
continue;
}
const start = part.state.time?.start;
const end = part.state.time?.end;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < created || end > completed || end < start) {
validTools = false;
continue;
}
rawToolIntervals.push([start, end]);
}
const toolDurationMs = validTools ? nonnegative(sumIntervalsDuration(mergeTimeIntervals(rawToolIntervals))) : null;
const adjustedLlmDurationMs = totalDurationMs !== null && toolDurationMs !== null
? nonnegative(totalDurationMs - toolDurationMs)
: null;
// Measure TTFT from first text or reasoning part start timestamp
let ttftMs: number | null = null;
for (const part of parts) {
if (part.type === 'text' || part.type === 'reasoning') {
const partStart = part.time?.start;
if (validWindow && partStart !== undefined && Number.isFinite(partStart) && partStart >= created && partStart <= completed) {
const delta = partStart - created;
ttftMs = ttftMs === null ? delta : Math.min(ttftMs, delta);
}
}
}
const inputTokens = nonnegative(info.tokens?.input);
const outputTokens = nonnegative(info.tokens?.output);
const reasoningTokens = nonnegative(info.tokens?.reasoning);
const cacheReadTokens = nonnegative(info.tokens?.cache?.read);
const cacheWriteTokens = nonnegative(info.tokens?.cache?.write);
const cost = nonnegative(info.cost);
return {
toolDurationMs,
adjustedLlmDurationMs,
ttftMs,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
cost,
};
}
/**
* Calculates telemetry metrics for the latest completed turn in the session.
* A turn encompasses all assistant steps since the preceding user message up to the final completed assistant step.
*/
export function getLatestCompletedTurnStats(
records: readonly SessionMessageRecord[] | null | undefined,
): CompletedTurnStats | null {
if (!records || records.length === 0) return null;
// Only the newest user-bounded turn qualifies. A partial newer turn must not
// be published as complete or silently replaced with an older turn's stats.
const lastCompletedAssistantIdx = records.length - 1;
if (records[lastCompletedAssistantIdx].info.role !== 'assistant') return null;
let turnStartIdx = -1;
for (let i = records.length - 1; i >= 0; i -= 1) {
const record = records[i];
if (record.info.role === 'user') {
turnStartIdx = i + 1;
break;
}
}
if (turnStartIdx === -1) return null;
const stepStatsList: CompletedStepStats[] = [];
for (let i = turnStartIdx; i <= lastCompletedAssistantIdx; i += 1) {
const record = records[i];
if (record.info.role === 'assistant') {
const stepStats = calculateCompletedStepStats(record);
if (!stepStats) return null;
stepStatsList.push(stepStats);
}
}
if (stepStatsList.length === 0) return null;
let totalLlmDurationMs: number | null = 0;
let totalToolDurationMs: number | null = 0;
let totalInputTokens: number | null = 0;
let totalOutputTokens: number | null = 0;
let totalReasoningTokens: number | null = 0;
let totalCacheReadTokens: number | null = 0;
let totalCacheWriteTokens: number | null = 0;
let totalCost: number | null = 0;
let totalTtft: number | null = 0;
for (const step of stepStatsList) {
totalLlmDurationMs = add(totalLlmDurationMs, step.adjustedLlmDurationMs);
totalToolDurationMs = add(totalToolDurationMs, step.toolDurationMs);
totalInputTokens = add(totalInputTokens, step.inputTokens);
totalOutputTokens = add(totalOutputTokens, step.outputTokens);
totalReasoningTokens = add(totalReasoningTokens, step.reasoningTokens);
totalCacheReadTokens = add(totalCacheReadTokens, step.cacheReadTokens);
totalCacheWriteTokens = add(totalCacheWriteTokens, step.cacheWriteTokens);
totalCost = add(totalCost, step.cost);
totalTtft = add(totalTtft, step.ttftMs);
}
const avgTtftMs = totalTtft === null ? null : totalTtft / stepStatsList.length;
const totalGeneratedTokens = add(totalOutputTokens, totalReasoningTokens);
const tokensPerSecond = totalGeneratedTokens !== null && totalLlmDurationMs !== null && totalLlmDurationMs > 0
? nonnegative(totalGeneratedTokens / (totalLlmDurationMs / 1000))
: null;
const cacheHit = totalInputTokens !== null && totalCacheReadTokens !== null && totalCacheWriteTokens !== null ? computeCacheHitRate({
input: totalInputTokens,
cache: { read: totalCacheReadTokens, write: totalCacheWriteTokens },
}) : null;
return {
lastAssistantMessageId: records[lastCompletedAssistantIdx].info.id,
stepsCount: stepStatsList.length,
totalLlmDurationMs,
totalToolDurationMs,
avgTtftMs,
tokensPerSecond,
responseTokensPerSecond: calculateResponseTokenRate(records[lastCompletedAssistantIdx]),
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
totalGeneratedTokens,
cacheHitPercent: cacheHit?.hasInput ? Math.round(cacheHit.percent) : null,
cost: totalCost,
};
}
@@ -39,7 +39,7 @@ export function InlineCommentInput({
const { isMobile } = useDeviceInfo();
const [text, setText] = React.useState(initialText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const saveShortcut = formatShortcutForDisplay('mod+enter');
const saveShortcut = formatShortcutForDisplay('enter');
void isEditing;
const handleTextChange = (value: string) => {
@@ -124,9 +124,9 @@ export function InlineCommentInput({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (isIMECompositionEvent(e)) return;
// As the placeholder promises: Cmd/Ctrl+Enter attaches, plain Enter
// breaks the line, Escape cancels.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
// Desktop Enter attaches; Shift+Enter and mobile Enter break the line.
// Keep Cmd/Ctrl+Enter available for hardware keyboards on mobile.
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === 'Escape') {
@@ -55,7 +55,7 @@ import {
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
const CONTEXT_PANEL_MIN_WIDTH = 380;
const CONTEXT_PANEL_MIN_WIDTH = 320;
const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const RESIZE_FOLLOW_INTERVAL_MS = 100;
@@ -491,10 +491,21 @@ export const ContextPanel: React.FC = () => {
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
const activeModeForWidth = activeTab?.mode ?? null;
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined;
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
const widthFallbackBase = availablePanelAreaWidth
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase));
const effectiveManualWidth = manualWidthFraction != null && availablePanelAreaWidth != null
? Math.round(manualWidthFraction * availablePanelAreaWidth)
: manualWidth;
const width = clampWidth(effectiveManualWidth ?? Math.round(widthFraction * widthFallbackBase));
// Convert legacy pixel-only preferences to a ratio the first time the
// available area is known, so existing users also get responsive sizing.
React.useEffect(() => {
if (!directoryKey || !activeModeForWidth || manualWidthFraction != null || manualWidth == null || availablePanelAreaWidth == null) return;
setContextPanelWidth(directoryKey, activeModeForWidth, manualWidth, availablePanelAreaWidth);
}, [activeModeForWidth, availablePanelAreaWidth, directoryKey, manualWidth, manualWidthFraction, setContextPanelWidth]);
const chatSessionIDs = React.useMemo(() => {
const ids: string[] = [];
for (const tab of tabs) {
@@ -516,8 +527,7 @@ export const ContextPanel: React.FC = () => {
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false);
// Tracks the panel area width so fraction-based surface defaults stay
// proportional as the window resizes; manual widths remain fixed px.
// Defaults and manually resized surfaces track the same available area.
React.useLayoutEffect(() => {
const parent = panelRef.current?.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') {
@@ -599,6 +609,7 @@ export const ContextPanel: React.FC = () => {
// Apply the final width once, letting the regular 200ms width transition
// carry the panel to the release position.
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
const availableWidth = resizeAvailableWidthRef.current;
resizingWidthRef.current = null;
resizeAvailableWidthRef.current = null;
if (resizeFollowTimerRef.current !== null) {
@@ -607,7 +618,7 @@ export const ContextPanel: React.FC = () => {
}
document.documentElement.style.cursor = '';
if (directoryKey && activeModeForWidth) {
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
}
setIsResizing(false);
activeResizePointerIDRef.current = null;
@@ -687,8 +698,8 @@ export const ContextPanel: React.FC = () => {
}
// Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode).
// ghostty-web listens in the bubble phase; stopping capture here would
// swallow the key before the terminal ever sees it (issue #2644).
// The terminal input listens in the bubble phase; stopping capture here
// would swallow the key before the terminal ever sees it (issue #2644).
if (isTerminalEventTarget(event.target)) {
return;
}
@@ -20,6 +20,7 @@ import {
} from './rawMessagePreview';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import { formatDateTimeForPreference } from '@/lib/timeFormat';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
type SessionMessage = { info: Message; parts: Part[] };
@@ -410,7 +411,7 @@ export const ContextPanelContent: React.FC = () => {
];
return (
<div className="h-full overflow-y-auto bg-background">
<ScrollableOverlay outerClassName="h-full" className="bg-background">
<div className="mx-auto w-full max-w-[52rem] px-5 py-6">
{/* ── Session header ── */}
@@ -644,6 +645,6 @@ export const ContextPanelContent: React.FC = () => {
</div>
</div>
</div>
</div>
</ScrollableOverlay>
);
};
+3 -1
View File
@@ -1076,7 +1076,9 @@ export const Header: React.FC = () => {
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
const titlebarLeftInset = React.useMemo(() => {
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
return '5.5rem';
// Native traffic lights have a fixed physical footprint. Keep this
// clearance in pixels so shrinking the interface cannot overlap them.
return '88px';
}
if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
@@ -151,6 +151,18 @@ mock.module('@/stores/useDesktopSshStore', () => ({ useDesktopSshStore: useDeskt
mock.module('@/lib/url', () => ({ openExternalUrl: async (url: string) => { openExternalCalls.push(url); } }));
mock.module('@/lib/openchamberConfig', () => ({
getProjectActionsState: async () => mockedActionsState,
// The button loads the merged setup; the test's actions are personal, so nothing asks for trust.
getProjectSetup: async () => ({
trust: { hash: null, trusted: true },
setupWorktree: [],
setupWorktreeWait: false,
projectActions: mockedActionsState.actions.map((action) => ({ ...action, source: 'personal' })),
projectActionsPrimaryId: null,
draftStarters: [],
shared: { status: 'missing', path: '.openchamber/project.json', setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
personal: { setupWorktree: [], setupWorktreeWait: null, setupWorktreeMode: 'append', projectActions: mockedActionsState.actions, projectActionsPrimaryId: null, draftStarters: [], hiddenSharedActionIds: [], sharedTrust: null },
}),
updateProjectSetup: async () => true,
}));
mock.module('@/lib/browser/announcedServers', () => ({ setAnnouncedDevServers: () => undefined }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory }));
@@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { terminalSnapshotSize } from '@/lib/terminalApi';
import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview';
import { setAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -25,9 +26,12 @@ import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import {
getProjectActionsState,
getProjectSetup,
type OpenChamberProjectAction,
type ProjectSetup,
type ProjectRef,
} from '@/lib/openchamberConfig';
import { ensureSharedSetupTrusted } from '@/lib/sharedTrustConfirmation';
import {
normalizeProjectActionDirectory,
PROJECT_ACTION_ICONS,
@@ -143,6 +147,8 @@ export const ProjectActionsButton = ({
const captureStartedActionMutationRevisions = useTerminalStore((state) => state.captureStartedActionMutationRevisions);
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
// The last merged setup, for the trust check before a shared action runs.
const setupRef = React.useRef<ProjectSetup | null>(null);
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
@@ -183,11 +189,12 @@ export const ProjectActionsButton = ({
setIsLoading(true);
try {
const state = await getProjectActionsState(stableProjectRef);
const setup = await getProjectSetup(stableProjectRef);
if (loadRequestIdRef.current !== requestId) {
return;
}
const filtered = state.actions;
setupRef.current = setup;
const filtered = setup.projectActions;
setActions(filtered);
setSelectedActionId((current) => {
if (current === AUTO_DISCOVER_ACTION_ID) {
@@ -641,7 +648,7 @@ export const ProjectActionsButton = ({
onEvent: (event) => {
if (!matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return;
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event));
if (event.status === 'running') {
useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'running', { expectedExecutionId: currentExecutionId });
}
@@ -851,7 +858,7 @@ export const ProjectActionsButton = ({
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return;
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0, terminalSnapshotSize(event));
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
if (event.purpose?.type === 'project-action') {
useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: event.purpose.actionId, executionId: event.purpose.executionId });
@@ -1035,11 +1042,25 @@ export const ProjectActionsButton = ({
void runAction(action);
}, [displayActions, executionDirectoryFor, runAction, projectActionRuns, selectedAction, stopAction]);
// A shared action comes from the repo: the first time one would run, the
// trust prompt shows the team's commands; "not this time" runs nothing.
const runActionWithTrust = React.useCallback(async (action: OpenChamberProjectAction) => {
if (action.source === 'shared' && stableProjectRef) {
const setup = setupRef.current?.trust.trusted ? setupRef.current : await getProjectSetup(stableProjectRef);
setupRef.current = setup;
if (!(await ensureSharedSetupTrusted(stableProjectRef, setup))) {
return;
}
setupRef.current = { ...setup, trust: { ...setup.trust, trusted: true } };
}
await runAction(action);
}, [runAction, stableProjectRef]);
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
setSelectedActionId(action.id);
if (!toggleStopIfRunning) {
void runAction(action);
void runActionWithTrust(action);
return;
}
@@ -1052,8 +1073,8 @@ export const ProjectActionsButton = ({
void stopAction(action);
return;
}
void runAction(action);
}, [executionDirectoryFor, runAction, projectActionRuns, stopAction]);
void runActionWithTrust(action);
}, [executionDirectoryFor, runActionWithTrust, projectActionRuns, stopAction]);
const openProjectActionsSettings = React.useCallback(() => {
if (!stableProjectRef?.id) {
@@ -1173,6 +1194,11 @@ export const ProjectActionsButton = ({
>
<Icon name={iconName} className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{entry.source === 'shared' ? (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-muted-foreground bg-[var(--surface-subtle)]">
{t('projectActions.menu.sharedBadge')}
</span>
) : null}
{isStopping || runState?.status === 'waiting-for-preview'
? <Icon name="loader-4" className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
@@ -1283,6 +1309,11 @@ export const ProjectActionsButton = ({
>
<Icon name={iconName} className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{entry.source === 'shared' ? (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-muted-foreground bg-[var(--surface-subtle)]">
{t('projectActions.menu.sharedBadge')}
</span>
) : null}
{isStopping || runState?.status === 'waiting-for-preview'
? <Icon name="loader-4" className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
@@ -3,9 +3,10 @@ import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
const SIDEBAR_CONTENT_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 280;
const SIDEBAR_MIN_WIDTH = 168;
const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps {
@@ -174,9 +175,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
aria-hidden={!isOpen}
>
{topBar}
<div className="min-h-0 flex-1 overflow-y-auto">
<ScrollableOverlay outerClassName="flex-1 min-h-0" disableHorizontal>
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</ScrollableOverlay>
</div>
</aside>
);
@@ -35,7 +35,7 @@ describe('issue #2644: Escape in terminal must not close the context panel', ()
expect(handler).toContain('event.stopPropagation()');
expect(handler).toContain('handleClose()');
// Guard must return before preventDefault/stopPropagation so ghostty-web's
// Guard must return before preventDefault/stopPropagation so the terminal input's
// bubble-phase keydown listener can forward Escape to the PTY.
const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)');
const preventIndex = handler.indexOf('event.preventDefault()');
@@ -264,7 +264,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
className={cn(
'flex items-center gap-3 bg-background',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
// Native traffic lights are fixed-size OS chrome, not scaled UI.
hasMacTrafficLights ? 'pl-[88px]' : 'pl-3',
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)}
style={dragRegionStyle}
@@ -16,6 +16,7 @@ import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { handleDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
import { getCurrentIntlLocale } from '@/lib/i18n';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
@@ -585,9 +586,17 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
filteredFavorites.map((entry) => [`${entry.providerID}:${entry.modelID}`, entry] as const),
), [filteredFavorites]);
React.useEffect(() => {
selectionStore.set(0);
}, [searchQuery, selectionStore]);
const initialSelectionIndex = searchQuery.trim() || !selectedModel ? 0 : Math.max(0,
flatModelList.findIndex((entry) => entry.providerID === selectedModel.providerID && entry.modelID === selectedModel.modelID),
);
React.useLayoutEffect(() => {
selectionStore.set(initialSelectionIndex);
// Opening or scrolling the list must not let a stationary pointer replace the current model.
keyboardOwnsSelectionRef.current = true;
lastMousePositionRef.current = null;
scrollIntoView(scrollRef.current, itemRefs.current[initialSelectionIndex]);
}, [initialSelectionIndex, searchQuery, selectedModel?.providerID, selectedModel?.modelID, selectionStore]);
const selectIndex = React.useCallback((index: number) => {
selectionStore.set(index);
@@ -608,10 +617,13 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
React.useEffect(() => {
onActiveEntryChange?.(flatModelList[selectionStore.getSnapshot()]);
}, [flatModelList, onActiveEntryChange, selectionStore]);
}, [flatModelList, initialSelectionIndex, onActiveEntryChange, selectionStore]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.defaultPrevented) return;
if (handleDropdownNavigationKey(event, (navigationKey) => {
moveSelection(navigationKey === 'ArrowDown' ? 1 : -1);
})) return;
event.stopPropagation();
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const selected = flatModelList[selectionStore.getSnapshot()];
@@ -13,7 +13,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { resolveWorktreeSetupCommands } from '@/lib/sharedTrustConfirmation';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunGroup } from '@/types/multirun';
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
@@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const desktopHeaderPaddingClass = React.useMemo(() => {
if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) {
// Match main app header: reserve space for Mac/iPadOS traffic lights.
return 'pl-[5.5rem]';
return 'pl-[88px]';
}
return 'pl-3';
}, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]);
@@ -280,7 +280,8 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
setIsLoadingSetupCommands(true);
(async () => {
try {
const commands = await getWorktreeSetupCommands(projectRef);
// The launcher prepares a run: the shared commands ask for trust here, before they are shown as the defaults.
const commands = await resolveWorktreeSetupCommands(projectRef);
if (!cancelled) setSetupCommands(commands);
} catch {
// Ignore
@@ -3,7 +3,7 @@ import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { cn } from '@/lib/utils';
@@ -79,11 +79,9 @@ export function ChooserScreen({ onCliAvailable, localAvailable = true }: Chooser
let cancelled = false;
void (async () => {
try {
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
const data = await loadDesktopSettings();
if (!data || cancelled) return;
const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
const value = data.opencodeBinary ?? '';
if (value) setOpencodeBinary(value);
} catch {
// ignore
@@ -3,7 +3,7 @@ import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -99,11 +99,9 @@ export function LocalSetupScreen({
let cancelled = false;
void (async () => {
try {
const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) return;
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
const data = await loadDesktopSettings();
if (!data || cancelled) return;
const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
const value = data.opencodeBinary ?? '';
if (value) {
setOpencodeBinary(value);
}
@@ -0,0 +1,91 @@
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 {
getSharedTrustConfirmationSnapshot,
settleSharedTrustConfirmation,
subscribeSharedTrustConfirmation,
type SharedTrustChoice,
} from '@/lib/sharedTrustConfirmation';
/**
* App-level dialog shown the first time a team's shared setup commands or
* shared actions (from `<repo>/.openchamber/project.json`) are about to run.
* It lists exactly what would run. Dismissing via the close button, Escape,
* or the backdrop counts as "run without the shared commands this time".
*/
export const SharedTrustConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeSharedTrustConfirmation,
getSharedTrustConfirmationSnapshot,
getSharedTrustConfirmationSnapshot,
);
const settle = React.useCallback((choice: SharedTrustChoice) => {
settleSharedTrustConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('skip');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('projects.sharedTrust.title')}</DialogTitle>
<DialogDescription>
{t('projects.sharedTrust.description', { path: request?.sharedPath ?? '' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{request && request.setupCommands.length > 0 ? (
<div className="space-y-1">
<p className="typography-meta text-muted-foreground">{t('projects.sharedTrust.setupCommands')}</p>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 font-mono text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{request.setupCommands.map((command, index) => (
<div key={`${index}-${command}`}>{command}</div>
))}
</div>
</div>
) : null}
{request && request.actions.length > 0 ? (
<div className="space-y-1">
<p className="typography-meta text-muted-foreground">{t('projects.sharedTrust.actions')}</p>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{request.actions.map((action) => (
<div key={action.id}>
<span>{action.name}</span>
<span className="text-muted-foreground">{' — '}</span>
<span className="font-mono">{action.command}</span>
</div>
))}
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('skip')}>
{t('projects.sharedTrust.skip')}
</Button>
<Button variant="default" onClick={() => settle('trust')}>
{t('projects.sharedTrust.trust')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,84 @@
import React, { act } from 'react';
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { ThemeProvider } from './ThemeProvider';
import { useUIStore } from '@/stores/useUIStore';
test('zoom works without App menu listeners and routes by focused content', async () => {
const dom = new Window({ url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({
window: dom, document: dom.document, navigator: dom.navigator,
Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node,
Event: dom.Event, CustomEvent: dom.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
})) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import('react-dom/client');
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
useUIStore.setState({ fontSize: 100, terminalFontSize: 14, editorFontSize: 13 });
const zoom = (action: string) => window.dispatchEvent(new CustomEvent('openchamber:zoom', { detail: action }));
try {
await act(async () => root.render(<ThemeProvider><input aria-label="composer" /></ThemeProvider>));
await act(async () => { zoom('zoom-in'); zoom('zoom-in'); });
expect(useUIStore.getState().fontSize).toBe(120);
expect(document.documentElement.style.fontSize).toBe('120%');
const terminal = document.createElement('input');
terminal.dataset.terminalOwner = 'test-terminal';
container.append(terminal);
terminal.focus();
await act(async () => zoom('zoom-in'));
expect(useUIStore.getState().terminalFontSize).toBe(15);
expect(useUIStore.getState().fontSize).toBe(120);
await act(async () => zoom('zoom-reset'));
expect(useUIStore.getState().terminalFontSize).toBe(14);
const editor = document.createElement('div');
editor.className = 'cm-editor';
const editorInput = document.createElement('textarea');
editor.append(editorInput);
container.append(editor);
editorInput.focus();
await act(async () => zoom('zoom-out'));
expect(useUIStore.getState().editorFontSize).toBe(12);
expect(useUIStore.getState().fontSize).toBe(120);
const browser = document.createElement('webview');
browser.tabIndex = 0;
container.append(browser);
browser.focus();
expect(document.activeElement).toBe(browser);
await act(async () => zoom('zoom-in'));
expect(useUIStore.getState().fontSize).toBe(120);
browser.blur();
await act(async () => zoom('zoom-reset'));
expect(useUIStore.getState().fontSize).toBe(100);
expect(document.documentElement.style.fontSize).toBe('');
const composer = document.createElement('div');
composer.dataset.chatInput = 'true';
composer.className = 'cm-editor';
const composerInput = document.createElement('textarea');
composer.append(composerInput);
container.append(composer);
composerInput.focus();
await act(async () => zoom('zoom-in'));
expect(useUIStore.getState().fontSize).toBe(110);
expect(useUIStore.getState().editorFontSize).toBe(12);
await act(async () => zoom('zoom-reset'));
await act(async () => root.unmount());
zoom('zoom-in');
expect(useUIStore.getState().fontSize).toBe(100);
} finally {
await act(async () => root.unmount());
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
await dom.happyDOM.close();
}
});
@@ -1,4 +1,5 @@
import React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useUIStore } from '@/stores/useUIStore';
interface ThemeProviderProps {
@@ -16,5 +17,33 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
applyPadding();
}, [fontSize, applyTypography, padding, applyPadding]);
React.useEffect(() => {
const handleZoom = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const action = event.detail;
if (action !== 'zoom-in' && action !== 'zoom-out' && action !== 'zoom-reset') return;
const active = document.activeElement;
if (active?.tagName === 'WEBVIEW' || active?.closest('webview')) return;
const state = useUIStore.getState();
const isTerminal = isTerminalEventTarget(active)
|| active?.matches('[data-terminal-hidden-input="true"]') === true;
const isEditor = active?.closest('.cm-editor') != null
&& active?.closest('[data-chat-input="true"]') == null;
if (action === 'zoom-reset') {
if (isTerminal) state.setTerminalFontSize(14);
else if (isEditor) state.setEditorFontSize(13);
else state.setFontSize(100);
} else if (isTerminal) {
state.setTerminalFontSize(state.terminalFontSize + (action === 'zoom-in' ? 1 : -1));
} else if (isEditor) {
state.setEditorFontSize(state.editorFontSize + (action === 'zoom-in' ? 1 : -1));
} else {
state.setFontSize(state.fontSize + (action === 'zoom-in' ? 10 : -10));
}
};
window.addEventListener('openchamber:zoom', handleZoom);
return () => window.removeEventListener('openchamber:zoom', handleZoom);
}, []);
return <>{children}</>;
};
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { reportSettingsSaveState } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import {
Select,
@@ -84,24 +84,9 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record<ResponseStylePreset, I18nKey> = {
};
const saveBehaviorSetting = async (settings: Partial<DesktopSettings>, fallbackError: string) => {
reportSettingsSaveState('saving');
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(await readApiError(response, fallbackError));
}
reportSettingsSaveState('saved');
} catch (error) {
reportSettingsSaveState('error');
throw error;
const result = await updateDesktopSettings(settings);
if (!result.ok) {
throw new Error(fallbackError);
}
};
@@ -130,12 +115,8 @@ export const BehaviorPage: React.FC = () => {
const load = async () => {
try {
const [settingsRes, agentsMdRes] = await Promise.all([
runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
signal: abort.signal,
}),
const [data, agentsMdRes] = await Promise.all([
loadDesktopSettings(),
runtimeFetch('/api/behavior/agents-md', {
method: 'GET',
headers: { Accept: 'application/json' },
@@ -144,18 +125,15 @@ export const BehaviorPage: React.FC = () => {
]);
let nextSettings: BehaviorSettingsState = DEFAULT_BEHAVIOR_SETTINGS;
if (settingsRes.ok) {
const data = await settingsRes.json();
if (data) {
nextSettings = {
...nextSettings,
optimizeSystemPrompt: data.optimizeSystemPrompt === true,
responseStyleEnabled: data.responseStyleEnabled === true,
responseStylePreset: sanitizeResponseStylePreset(data.responseStylePreset),
responseStyleCustomInstructions: typeof data.responseStyleCustomInstructions === 'string'
? data.responseStyleCustomInstructions
: '',
responseStyleCustomInstructions: data.responseStyleCustomInstructions ?? '',
};
if (typeof data.globalBehaviorPrompt === 'string') {
if (data.globalBehaviorPrompt !== undefined) {
nextSettings = { ...nextSettings, prompt: data.globalBehaviorPrompt };
}
}
@@ -14,12 +14,11 @@ import {
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, 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';
@@ -79,75 +78,23 @@ export const DefaultsSettings: React.FC = () => {
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: {
defaultModel?: string;
defaultVariant?: string;
defaultAgent?: string;
smallModelUseDefault?: boolean;
smallModelOverride?: string;
walkthroughModelOverride?: string;
} | null = null;
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = result?.settings;
if (settings) {
const raw = settings as Record<string, unknown>;
data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
defaultVariant:
typeof raw.defaultVariant === 'string'
? (raw.defaultVariant as string)
: undefined,
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
walkthroughModelOverride:
typeof raw.walkthroughModelOverride === 'string' ? raw.walkthroughModelOverride : undefined,
};
}
} catch {
// fall through
}
}
}
if (!data) {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
const data = await loadDesktopSettings();
if (data) {
const model =
typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0
? data.defaultModel.trim()
: undefined;
const variant =
typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0
? data.defaultVariant.trim()
: undefined;
const agent =
typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0
? data.defaultAgent.trim()
: undefined;
const model = data.defaultModel?.trim() || undefined;
const variant = data.defaultVariant?.trim() || undefined;
const agent = data.defaultAgent?.trim() || undefined;
if (model !== undefined) setDefaultModel(model);
if (variant !== undefined) setDefaultVariant(variant);
if (agent !== undefined) setDefaultAgent(agent);
if (typeof data.smallModelUseDefault === 'boolean') setSmallModelUseDefault(data.smallModelUseDefault);
if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
setSmallModelOverride(data.smallModelOverride.trim());
if (data.smallModelUseDefault !== undefined) setSmallModelUseDefault(data.smallModelUseDefault);
const smallOverride = data.smallModelOverride?.trim();
if (smallOverride) {
setSmallModelOverride(smallOverride);
}
if (typeof data.walkthroughModelOverride === 'string' && data.walkthroughModelOverride.trim()) {
setWalkthroughModelOverride(data.walkthroughModelOverride.trim());
const walkthroughOverride = data.walkthroughModelOverride?.trim();
if (walkthroughOverride) {
setWalkthroughModelOverride(walkthroughOverride);
}
}
} catch (error) {
@@ -181,14 +128,6 @@ export const DefaultsSettings: React.FC = () => {
try {
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
});
if (!response.ok) {
console.warn('Failed to save default model to server:', response.status, response.statusText);
}
} catch (error) {
console.warn('Failed to save default model:', error);
}
@@ -1,7 +1,6 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import {
getDesktopLanAddress,
@@ -16,14 +15,13 @@ import {
setDesktopMinimizeToTray,
} from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import {
SettingsSection,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
SettingsStackedField,
SETTINGS_ICON_BUTTON_CLASS,
} from '@/components/sections/shared/SettingsSection';
export const DesktopNetworkSettings: React.FC = () => {
@@ -34,9 +32,11 @@ export const DesktopNetworkSettings: React.FC = () => {
&& window.__OPENCHAMBER_PLATFORM__ === 'darwin';
const [savedValue, setSavedValue] = React.useState(false);
const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState('');
// The password is write-only: the server says whether one is set, and the
// page sends a value only when the user types a new one or removes it.
const [hasSavedPassword, setHasSavedPassword] = React.useState(false);
const [draftPassword, setDraftPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [removePassword, setRemovePassword] = React.useState(false);
const [lanAccessActive, setLanAccessActive] = React.useState(false);
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
@@ -64,36 +64,23 @@ export const DesktopNetworkSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const data = await loadDesktopSettings();
if (!data) {
throw new Error(t('settings.openchamber.desktopNetwork.error.loadFailed'));
}
const data = (await response.json().catch(() => null)) as null | {
desktopLanAccessEnabled?: unknown;
desktopUiPassword?: unknown;
desktopLanAccessActive?: unknown;
desktopLanAccessBlockedReason?: unknown;
desktopMacMenuBarEnabled?: unknown;
};
if (cancelled) {
return;
}
const enabled = data?.desktopLanAccessEnabled === true;
const password = typeof data?.desktopUiPassword === 'string' ? data.desktopUiPassword : '';
const enabled = data.desktopLanAccessEnabled === true;
setSavedValue(enabled);
setDraftValue(enabled);
setSavedPassword(password);
setDraftPassword(password);
setLanAccessActive(data?.desktopLanAccessActive === true);
setLanAccessBlockedReason(
typeof data?.desktopLanAccessBlockedReason === 'string' ? data.desktopLanAccessBlockedReason : null
);
const macMenuBarEnabled = data?.desktopMacMenuBarEnabled !== false;
setHasSavedPassword(data.hasDesktopUiPassword === true);
setDraftPassword('');
setRemovePassword(false);
setLanAccessActive(data.desktopLanAccessActive === true);
setLanAccessBlockedReason(data.desktopLanAccessBlockedReason ?? null);
const macMenuBarEnabled = data.desktopMacMenuBarEnabled !== false;
setSavedMacMenuBarEnabled(macMenuBarEnabled);
setDraftMacMenuBarEnabled(macMenuBarEnabled);
setError(null);
@@ -196,8 +183,10 @@ export const DesktopNetworkSettings: React.FC = () => {
};
}, [draftValue, isLocalDesktop]);
const nextPassword = draftPassword.trim();
const passwordDirty = nextPassword.length > 0 || removePassword;
const isDirty = draftValue !== savedValue
|| draftPassword !== savedPassword
|| passwordDirty
|| draftMacMenuBarEnabled !== savedMacMenuBarEnabled;
const currentPort = React.useMemo(() => {
if (typeof window === 'undefined') {
@@ -215,17 +204,24 @@ export const DesktopNetworkSettings: React.FC = () => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const lanUrl = draftValue && lanAccessActive && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
const lanRequiresPassword = draftValue && !draftPassword.trim();
const passwordWillBeSet = nextPassword.length > 0 || (hasSavedPassword && !removePassword);
const lanRequiresPassword = draftValue && !passwordWillBeSet;
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
const handlePasswordChange = React.useCallback((value: string) => {
setDraftPassword(value);
if (!value.trim()) {
setDraftValue(false);
if (value.trim()) {
setRemovePassword(false);
}
}, []);
const handleRemovePassword = React.useCallback(() => {
setDraftPassword('');
setRemovePassword(true);
setDraftValue(false);
}, []);
const handleLaunchAtLoginToggle = React.useCallback(async () => {
if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
return;
@@ -310,25 +306,25 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(null);
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
desktopLanAccessEnabled: draftValue,
desktopUiPassword: draftPassword,
desktopMacMenuBarEnabled: draftMacMenuBarEnabled,
}),
const result = await updateDesktopSettings({
desktopLanAccessEnabled: draftValue,
// Omitted when unchanged: the server keeps the password it has.
...(nextPassword ? { desktopUiPassword: nextPassword } : removePassword ? { desktopUiPassword: '' } : {}),
desktopMacMenuBarEnabled: draftMacMenuBarEnabled,
});
if (!response.ok) {
if (!result.ok) {
throw new Error(t('settings.openchamber.desktopNetwork.error.saveFailed'));
}
setSavedValue(draftValue);
setSavedPassword(draftPassword);
if (nextPassword) {
setHasSavedPassword(true);
} else if (removePassword) {
setHasSavedPassword(false);
}
setDraftPassword('');
setRemovePassword(false);
setSavedMacMenuBarEnabled(draftMacMenuBarEnabled);
const restarted = await restartDesktopApp();
@@ -339,7 +335,7 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.saveFailed'));
setIsSaving(false);
}
}, [draftMacMenuBarEnabled, draftPassword, draftValue, isDirty, t]);
}, [draftMacMenuBarEnabled, draftValue, isDirty, nextPassword, removePassword, t]);
if (!isLocalDesktop) {
return null;
@@ -420,26 +416,29 @@ export const DesktopNetworkSettings: React.FC = () => {
>
<Input
id="desktop-ui-password"
type={showPassword ? 'text' : 'password'}
type="password"
className="h-8 min-w-0 flex-1"
value={draftPassword}
onChange={(event) => handlePasswordChange(event.target.value)}
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
placeholder={t(hasSavedPassword && !removePassword
? 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder'
: 'settings.openchamber.desktopPassword.field.passwordPlaceholder')}
disabled={isLoading || isSaving}
required={draftValue}
required={draftValue && !passwordWillBeSet}
aria-invalid={lanRequiresPassword}
/>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => setShowPassword((current: boolean) => !current)}
className={SETTINGS_ICON_BUTTON_CLASS}
aria-label={t(showPassword ? 'settings.openchamber.desktopPassword.actions.hidePassword' : 'settings.openchamber.desktopPassword.actions.showPassword')}
aria-pressed={showPassword}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} className="h-4 w-4" />
</Button>
{hasSavedPassword && !removePassword ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleRemovePassword}
disabled={isLoading || isSaving}
className="shrink-0 !font-normal"
>
{t('settings.openchamber.desktopPassword.actions.removePassword')}
</Button>
) : null}
</SettingsStackedField>
<div className={SETTINGS_OPTION_STACK_CLASS}>
@@ -1,11 +1,9 @@
import React from 'react';
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
SettingsSection,
SettingsControlGroup,
@@ -32,58 +30,16 @@ export const GitSettings: React.FC = () => {
[t]
);
type GitSettingsPayload = {
gitmojiEnabled?: boolean;
gitChangesViewMode?: 'flat' | 'tree';
};
// Load current settings
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: GitSettingsPayload | null = null;
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = result?.settings;
if (settings) {
data = {
gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean'
? ((settings as Record<string, unknown>).gitmojiEnabled as boolean)
: undefined,
gitChangesViewMode:
(settings as Record<string, unknown>).gitChangesViewMode === 'flat'
|| (settings as Record<string, unknown>).gitChangesViewMode === 'tree'
? ((settings as Record<string, unknown>).gitChangesViewMode as 'flat' | 'tree')
: undefined,
};
}
} catch {
// fall through
}
}
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
const data = await loadDesktopSettings();
if (data) {
if (typeof data.gitmojiEnabled === 'boolean') {
if (data.gitmojiEnabled !== undefined) {
setSettingsGitmojiEnabled(data.gitmojiEnabled);
}
if (data.gitChangesViewMode === 'flat' || data.gitChangesViewMode === 'tree') {
if (data.gitChangesViewMode !== undefined) {
setGitChangesViewMode(data.gitChangesViewMode);
}
}
@@ -176,6 +176,7 @@ const VisualSectionContent: React.FC = () => {
'terminalFontSize',
'editorFontSize',
'spacing',
'scrollbars',
'inputBarOffset',
]} />;
};
@@ -1,5 +1,4 @@
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
@@ -27,7 +26,7 @@ import {
} from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -301,7 +300,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'scrollbars' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -406,7 +405,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setEnterToSend = useUIStore(state => state.setEnterToSend);
const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured);
const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured);
const isExpandedInput = useUIStore(state => state.isExpandedInput);
const enterSendSelected = enterToSendConfigured ? enterToSend : !isMobile;
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -455,6 +454,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
);
const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled);
const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled);
const alwaysShowScrollbars = useUIStore(state => state.alwaysShowScrollbars === true);
const setAlwaysShowScrollbars = useUIStore(state => state.setAlwaysShowScrollbars);
const showWindowControlsPosition = usesFramelessElectronChrome();
const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition);
const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition);
@@ -683,14 +684,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('scrollbars') && !hasThemeSettings) || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile);
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| (shouldShow('sessionGoal') && !isVSCode)
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
|| shouldShow('messageTransport')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('activityRenderMode')
|| shouldShow('collapsibleUserMessages')
|| shouldShow('stickyUserHeader')
|| (shouldShow('promptNavigatorEnabled') && !isVSCode)
@@ -712,7 +713,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend');
const showBehaviorDisplaySettings = shouldShow('chatRenderMode')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted');
|| shouldShow('activityRenderMode');
const showTransportSection = shouldShow('messageTransport');
const showBehaviorMessageOptions = shouldShow('userMessageRendering')
|| shouldShow('mermaidRendering')
@@ -850,24 +851,19 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const loadPwaInstallName = async () => {
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const settings = await loadDesktopSettings();
if (!response.ok) {
if (!settings) {
if (!cancelled) {
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
}
return;
}
const settings = await response.json().catch(() => ({}));
const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : '';
const raw = settings.pwaAppName ?? '';
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64);
const orientation = normalizePwaOrientation(settings?.pwaOrientation);
const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode);
const orientation = normalizePwaOrientation(settings.pwaOrientation);
const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode);
if (!cancelled) {
if (showPwaInstallNameSetting) {
@@ -902,6 +898,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
};
}, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]);
const scrollbarSetting = shouldShow('scrollbars') ? (
<SettingsCheckboxRow
checked={alwaysShowScrollbars}
onChange={setAlwaysShowScrollbars}
label={t('settings.openchamber.visual.field.alwaysShowScrollbars')}
info={t('settings.openchamber.visual.field.alwaysShowScrollbarsHint')}
settingsItem="appearance.scrollbars"
/>
) : null;
return (
<div className="space-y-0">
@@ -1011,6 +1017,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
</SettingsInset>
)}
{scrollbarSetting && <SettingsInset>{scrollbarSetting}</SettingsInset>}
</SettingsSection>
)}
@@ -1488,6 +1495,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsTwoColumn>
) : null}
{!hasThemeSettings && scrollbarSetting}
</SettingsSection>
)}
@@ -1677,8 +1685,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsControlGroup>
)}
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
<SettingsControlGroup title={t('settings.openchamber.visual.section.activityDefault')}>
{shouldShow('activityRenderMode') && (
<SettingsControlGroup title={t('settings.openchamber.visual.section.activityDefault')} settingsItem="chat.activity-default">
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.section.activityDefaultAria')}>
{ACTIVITY_RENDER_MODE_OPTIONS.map((option) => (
<SettingsRadioOption
@@ -2103,8 +2111,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
contentClassName="space-y-6"
>
{(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && (
<div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('persistDraft') && (
<SettingsCheckboxRow
checked={persistChatDraft}
@@ -2124,11 +2134,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="chat.spellcheck"
/>
)}
</div>
)}
{shouldShow('largeTextPaste') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.largeTextPaste')}
info={t('settings.openchamber.visual.field.largeTextPasteHint')}
description={t('settings.openchamber.visual.field.largeTextPasteHint')}
settingsItem="chat.large-text-paste"
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
@@ -2145,14 +2157,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsControlGroup>
)}
{shouldShow('enterToSend') && (
<SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange}
label={t('settings.openchamber.visual.field.enterToSend')}
info={t('settings.openchamber.visual.field.enterToSendHint')}
ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
<SettingsControlGroup
title={t('settings.openchamber.visual.field.enterToSend')}
description={t('settings.openchamber.visual.field.enterToSendHint')}
settingsItem="chat.enter-to-send"
/>
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.enterToSend')}>
<SettingsRadioOption
selected={enterSendSelected}
onSelect={() => handleEnterToSendChange(true)}
label={t('settings.openchamber.visual.option.enterToSend.enter.label')}
ariaLabel={t('settings.openchamber.visual.option.enterToSend.enter.label')}
/>
<SettingsRadioOption
selected={!enterSendSelected}
onSelect={() => handleEnterToSendChange(false)}
label={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
ariaLabel={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
/>
</SettingsRadioGroup>
</SettingsControlGroup>
)}
</SettingsSection>
)}
@@ -11,11 +11,10 @@ import {
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isWindowsArm64 } from '@/lib/platform';
import { toast } from '@/components/ui';
@@ -31,19 +30,11 @@ export const OpenCodeCliSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return;
}
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
const data = await loadDesktopSettings();
if (cancelled || !data) {
return;
}
const next = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
setValue(next);
setValue(data.opencodeBinary ?? '');
} catch {
// ignore
} finally {
@@ -8,8 +8,8 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { requestFileAccess } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { requestFileAccess, type DesktopSettings } from '@/lib/desktop';
import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
@@ -524,37 +524,27 @@ export const TunnelSettings: React.FC = () => {
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
try {
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
const [checkRes, statusRes, loadedSettings, providersRes] = await Promise.all([
runtimeFetch('/api/openchamber/tunnel/check', { signal }),
runtimeFetch('/api/openchamber/tunnel/status', { signal }),
runtimeFetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
loadDesktopSettings(),
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
]);
const checkData = (await checkRes.json()) as TunnelCheckResponse;
const statusData = (await statusRes.json()) as TunnelStatusResponse;
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const settingsData: DesktopSettings = loadedSettings ?? {};
const providersData = providersRes.ok ? await providersRes.json() : {};
const loadedBootstrapTtl = statusData.ttlConfig?.bootstrapTtlMs
?? (settingsData?.tunnelBootstrapTtlMs === null
? null
: typeof settingsData?.tunnelBootstrapTtlMs === 'number'
? settingsData.tunnelBootstrapTtlMs
: 30 * 60 * 1000);
?? (settingsData.tunnelBootstrapTtlMs === undefined ? 30 * 60 * 1000 : settingsData.tunnelBootstrapTtlMs);
const loadedSessionTtl = typeof statusData.ttlConfig?.sessionTtlMs === 'number'
? statusData.ttlConfig.sessionTtlMs
: typeof settingsData?.tunnelSessionTtlMs === 'number'
? settingsData.tunnelSessionTtlMs
: 8 * 60 * 60 * 1000;
: settingsData.tunnelSessionTtlMs ?? 8 * 60 * 60 * 1000;
const loadedMode: TunnelMode = toUiTunnelMode(statusData.mode ?? settingsData?.tunnelMode);
const loadedProvider = typeof settingsData?.tunnelProvider === 'string' && settingsData.tunnelProvider.trim().length > 0
? settingsData.tunnelProvider.trim().toLowerCase()
: 'cloudflare';
const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string'
? settingsData.managedLocalTunnelConfigPath.trim() || null
: null;
const loadedMode: TunnelMode = toUiTunnelMode(statusData.mode ?? settingsData.tunnelMode);
const loadedProvider = settingsData.tunnelProvider ?? 'cloudflare';
const loadedManagedLocalConfigPath = settingsData.managedLocalTunnelConfigPath ?? null;
const dependencyAvailable = applyDependencyCheck(checkData, loadedProvider);
const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets);
@@ -14,11 +14,13 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi';
import {
getWorktreeSetupCommands,
getWorktreeSetupWaitEnabled,
getProjectSetup,
saveWorktreeSetupCommands,
saveWorktreeSetupWaitEnabled,
updateProjectSetup,
updateSharedProjectSetup,
} from '@/lib/openchamberConfig';
import { resetSharedSetupTrust } from '@/lib/sharedTrustConfirmation';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree';
@@ -54,6 +56,15 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
const [sharedSetupCommands, setSharedSetupCommands] = React.useState<string[]>([]);
const [sharedConfigPath, setSharedConfigPath] = React.useState('');
const [replaceSharedCommands, setReplaceSharedCommands] = React.useState(false);
// The trust answer covers the repository's setup commands and actions; it is
// shown here, next to the commands it is mostly about.
const [sharedTrusted, setSharedTrusted] = React.useState(false);
const [isResettingTrust, setIsResettingTrust] = React.useState(false);
const [isSharing, setIsSharing] = React.useState(false);
const [reloadCounter, setReloadCounter] = React.useState(0);
const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
const [commandsSnapshot, setCommandsSnapshot] = React.useState<string | null>(null);
@@ -148,19 +159,24 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
(async () => {
try {
const [commands, waitForSetup] = await Promise.all([
getWorktreeSetupCommands(projectRef),
getWorktreeSetupWaitEnabled(projectRef),
]);
// The page edits the user's own commands; the team's shared commands
// come from the repo, run first, and are never copied into the personal file.
const setup = await getProjectSetup(projectRef);
if (!cancelled) {
const commands = setup.personal.setupWorktree;
const nextCommands = commands.length > 0 ? commands : [''];
setSetupCommands(nextCommands);
setSharedSetupCommands(setup.shared.setupWorktree);
setSharedConfigPath(setup.shared.path);
setReplaceSharedCommands(setup.personal.setupWorktreeMode === 'replace');
setSharedTrusted(setup.trust.hash !== null && setup.trust.trusted);
setCommandsSnapshot(JSON.stringify(nextCommands));
setWaitForSetupCommands(waitForSetup);
setWaitForSetupCommands(setup.setupWorktreeWait);
}
} catch {
if (!cancelled) {
setSetupCommands(['']);
setSharedSetupCommands([]);
setCommandsSnapshot(JSON.stringify(['']));
setWaitForSetupCommands(false);
}
@@ -174,8 +190,71 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
return () => {
cancelled = true;
};
}, [projectRef, reloadCounter]);
const reload = React.useCallback(() => setReloadCounter((count) => count + 1), []);
// Sharing moves a command between the two files: into the repo file first,
// then out of the personal list; the lists reload from disk afterwards.
const shareCommand = React.useCallback(async (index: number) => {
if (!projectRef || isSharing) return;
const command = setupCommands[index]?.trim();
if (!command) return;
setIsSharing(true);
try {
const shared = await updateSharedProjectSetup(projectRef, {
setupWorktree: [...sharedSetupCommands.filter((entry) => entry !== command), command],
});
if (!shared) {
toast.error(t('settings.projects.shared.toast.shareFailed'));
return;
}
await saveWorktreeSetupCommands(projectRef, setupCommands.filter((_entry, position) => position !== index));
reload();
} finally {
setIsSharing(false);
}
}, [isSharing, projectRef, reload, setupCommands, sharedSetupCommands, t]);
const makeCommandPersonal = React.useCallback(async (command: string) => {
if (!projectRef || isSharing) return;
setIsSharing(true);
try {
const shared = await updateSharedProjectSetup(projectRef, {
setupWorktree: sharedSetupCommands.filter((entry) => entry !== command),
});
if (!shared) {
toast.error(t('settings.projects.shared.toast.shareFailed'));
return;
}
await saveWorktreeSetupCommands(projectRef, [...setupCommands.filter((entry) => entry.trim().length > 0), command]);
reload();
} finally {
setIsSharing(false);
}
}, [isSharing, projectRef, reload, setupCommands, sharedSetupCommands, t]);
const handleResetTrust = React.useCallback(async () => {
if (!projectRef) return;
setIsResettingTrust(true);
try {
if (await resetSharedSetupTrust(projectRef)) {
setSharedTrusted(false);
}
} finally {
setIsResettingTrust(false);
}
}, [projectRef]);
const handleReplaceSharedCommandsChange = React.useCallback(async (next: boolean) => {
if (!projectRef) return;
setReplaceSharedCommands(next);
if (!(await updateProjectSetup(projectRef, { setupWorktreeMode: next ? 'replace' : 'append' }))) {
toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed'));
setReplaceSharedCommands(!next);
}
}, [projectRef, t]);
const persistSetupCommands = React.useCallback(async (commands: string[]): Promise<boolean> => {
if (!projectRef) {
return false;
@@ -385,6 +464,45 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
<p className="typography-meta text-muted-foreground">{t('settings.openchamber.worktrees.setup.loading')}</p>
) : (
<div className={cn('space-y-2', PROJECT_SETTINGS_CONTROL_WIDTH)}>
{sharedSetupCommands.length > 0 ? (
<div className="space-y-1 pb-1">
<p className="typography-meta text-muted-foreground">
{t('settings.projects.shared.commandsFromRepo', { path: sharedConfigPath })}
</p>
{sharedSetupCommands.map((command, index) => (
<div key={`shared-${index}`} className="flex items-center gap-2">
<span className={cn('min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground', replaceSharedCommands && 'line-through opacity-60')}>{command}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-muted-foreground bg-[var(--surface-subtle)]">
{t('settings.projects.shared.badge')}
</span>
<Button type="button" variant="ghost" size="xs" className="!font-normal shrink-0" disabled={isSharing} title={t('settings.projects.shared.actions.makePersonalTitle')} onClick={() => void makeCommandPersonal(command)}>
{t('settings.projects.shared.actions.makePersonal')}
</Button>
</div>
))}
{sharedTrusted ? (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.shared.trusted')}</span>
<Button type="button" variant="ghost" size="xs" className="!font-normal" disabled={isResettingTrust} onClick={() => void handleResetTrust()}>
{t('settings.projects.shared.resetTrust')}
</Button>
</div>
) : null}
<label
data-settings-item="projects.worktree.setup.replace"
className="flex cursor-pointer items-center gap-2 py-1"
>
<Checkbox
checked={replaceSharedCommands}
onChange={(next) => void handleReplaceSharedCommandsChange(next)}
ariaLabel={t('settings.projects.shared.replaceModeAria')}
/>
<span className={cn('typography-ui-label font-normal', replaceSharedCommands ? 'text-foreground' : 'text-foreground/60')}>
{t('settings.projects.shared.replaceMode')}
</span>
</label>
</div>
) : null}
{setupCommands.map((command, index) => (
<div key={index} className="flex w-full gap-2">
<Input
@@ -394,6 +512,19 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
className="h-7 min-w-0 flex-1 font-mono text-xs"
/>
{command.trim() ? (
<Button
type="button"
variant="ghost"
size="xs"
className="!font-normal h-7 shrink-0"
disabled={isSharing || commandsHaveChanges}
title={commandsHaveChanges ? t('settings.projects.shared.actions.shareAfterSave') : t('settings.projects.shared.actions.shareTitle', { path: sharedConfigPath || '.openchamber/project.json' })}
onClick={() => void shareCommand(index)}
>
{t('settings.projects.shared.actions.share')}
</Button>
) : null}
<Button
type="button"
variant="ghost"
@@ -7,16 +7,40 @@ import { I18nProvider } from '@/lib/i18n';
const desktopSshState = { instances: [], load: async () => undefined };
mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false }));
mock.module('@/stores/useDesktopSshStore', () => ({
useDesktopSshStore: <T,>(selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState),
}));
mock.module('@/lib/openchamberConfig', () => ({
getProjectActionsState: async () => ({
actions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
primaryActionId: null,
getProjectSetup: async () => ({
trust: { hash: 'sha256:abc', trusted: true },
setupWorktree: [],
setupWorktreeWait: false,
projectActions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build', source: 'personal' }],
projectActionsPrimaryId: null,
draftStarters: [],
shared: {
status: 'ok',
path: '.openchamber/project.json',
setupWorktree: [],
setupWorktreeWait: null,
projectActions: [{ id: 'dev', name: 'Team dev', command: 'bun run dev', icon: null }],
draftStarters: [],
plansDir: null,
},
personal: {
setupWorktree: [],
setupWorktreeWait: null,
setupWorktreeMode: 'append',
projectActions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
projectActionsPrimaryId: null,
draftStarters: [],
hiddenSharedActionIds: [],
sharedTrust: { hash: 'sha256:abc', trustedAt: 1 },
},
}),
saveProjectActionsState: async () => true,
updateProjectSetup: async () => true,
updateSharedProjectSetup: async () => null,
}));
const { ProjectActionsSection } = await import('./ProjectActionsSection');
@@ -77,4 +101,24 @@ describe('ProjectActionsSection', () => {
expect(runInTrigger?.textContent).toContain('Current worktree');
expect(runInTrigger?.textContent).not.toContain('__project__');
});
test('lists the team\'s shared actions read-only, marked as shared, above the editable ones', async () => {
await act(async () => {
root.render(
<I18nProvider>
<ProjectActionsSection projectRef={{ id: 'project-1', path: '/repo' }} />
</I18nProvider>,
);
await Promise.resolve();
});
const text = host.textContent ?? '';
expect(text).toContain('Team dev');
expect(text).toContain('Stored in the repository (.openchamber/project.json)');
// The shared row is not a collapsible editor: no button carries its name.
const sharedTrigger = Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Team dev'));
expect(sharedTrigger).toBe(undefined);
expect(text.indexOf('Team dev')).toBeLessThan(text.indexOf('Build'));
});
});
@@ -25,8 +25,10 @@ import { Icon } from '@/components/icon/Icon';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { isDesktopShell } from '@/lib/desktop';
import {
getProjectActionsState,
getProjectSetup,
saveProjectActionsState,
updateProjectSetup,
updateSharedProjectSetup,
type OpenChamberProjectAction,
type ProjectRef,
} from '@/lib/openchamberConfig';
@@ -78,6 +80,14 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const [actions, setActions] = React.useState<EditableProjectAction[]>([]);
// Read-only here: the team's actions from the repo file, and whether that
// file could be read at all (a broken file is shown, never treated as empty).
const [sharedActions, setSharedActions] = React.useState<OpenChamberProjectAction[]>([]);
const [sharedState, setSharedState] = React.useState<{ path: string; status: 'missing' | 'ok' | 'invalid'; reason?: string } | null>(null);
const [hiddenSharedIds, setHiddenSharedIds] = React.useState<string[]>([]);
const [isSharing, setIsSharing] = React.useState(false);
const reloadCounterRef = React.useRef(0);
const [reloadCounter, setReloadCounter] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
const [initialSnapshot, setInitialSnapshot] = React.useState<string | null>(null);
const [expandedActions, setExpandedActions] = React.useState<Record<string, boolean>>({});
@@ -97,17 +107,25 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
(async () => {
try {
const state = await getProjectActionsState(projectRef);
// The page edits the user's own actions; a teammate's shared actions
// are read from the repo and must never be copied into the personal file.
const setup = await getProjectSetup(projectRef);
if (cancelled) {
return;
}
setActions(state.actions);
setInitialSnapshot(JSON.stringify({ actions: state.actions }));
setActions(setup.personal.projectActions);
setSharedActions(setup.shared.projectActions);
setSharedState({ path: setup.shared.path, status: setup.shared.status, reason: setup.shared.reason });
setHiddenSharedIds(setup.personal.hiddenSharedActionIds);
setInitialSnapshot(JSON.stringify({ actions: setup.personal.projectActions }));
} catch {
if (cancelled) {
return;
}
setActions([]);
setSharedActions([]);
setSharedState(null);
setHiddenSharedIds([]);
setInitialSnapshot(JSON.stringify({ actions: [] }));
} finally {
if (!cancelled) {
@@ -119,7 +137,79 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
return () => {
cancelled = true;
};
}, [projectRef]);
}, [projectRef, reloadCounter]);
const reload = React.useCallback(() => {
reloadCounterRef.current += 1;
setReloadCounter(reloadCounterRef.current);
}, []);
const notifyActionsUpdated = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { detail: { projectId: projectRef.id } }));
}
}, [projectRef.id]);
// Sharing moves an action between the two files: first into the repo file,
// then out of the personal one (a failure after the first step leaves the
// action visible once, as personal, which the merge resolves). The lists
// reload from the server afterwards so both blocks show what is on disk.
const shareAction = React.useCallback(async (action: EditableProjectAction) => {
if (isSharing) return;
setIsSharing(true);
try {
const shared = await updateSharedProjectSetup(projectRef, {
projectActions: [...sharedActions.filter((entry) => entry.id !== action.id), action],
});
if (!shared) {
toast.error(t('settings.projects.shared.toast.shareFailed'));
return;
}
await saveProjectActionsState(projectRef, {
actions: actions.filter((entry) => entry.id !== action.id),
primaryActionId: null,
});
reload();
notifyActionsUpdated();
} finally {
setIsSharing(false);
}
}, [actions, isSharing, notifyActionsUpdated, projectRef, reload, sharedActions, t]);
const makeActionPersonal = React.useCallback(async (action: OpenChamberProjectAction) => {
if (isSharing) return;
setIsSharing(true);
try {
const shared = await updateSharedProjectSetup(projectRef, {
projectActions: sharedActions.filter((entry) => entry.id !== action.id),
});
if (!shared) {
toast.error(t('settings.projects.shared.toast.shareFailed'));
return;
}
await saveProjectActionsState(projectRef, {
actions: [...actions.filter((entry) => entry.id !== action.id), action],
primaryActionId: null,
});
reload();
notifyActionsUpdated();
} finally {
setIsSharing(false);
}
}, [actions, isSharing, notifyActionsUpdated, projectRef, reload, sharedActions, t]);
const setSharedActionHidden = React.useCallback(async (actionId: string, hidden: boolean) => {
const next = hidden
? [...hiddenSharedIds.filter((id) => id !== actionId), actionId]
: hiddenSharedIds.filter((id) => id !== actionId);
setHiddenSharedIds(next);
if (!(await updateProjectSetup(projectRef, { hiddenSharedActionIds: next }))) {
toast.error(t('settings.projects.actions.toast.saveFailed'));
setHiddenSharedIds(hiddenSharedIds);
return;
}
notifyActionsUpdated();
}, [hiddenSharedIds, notifyActionsUpdated, projectRef, t]);
const desktopForwardOptions = React.useMemo(() => {
if (!isDesktopShellApp) {
@@ -243,11 +333,44 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
)}
contentClassName="space-y-0"
>
{!isLoading && sharedState?.status === 'invalid' ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.projects.shared.invalid', { path: sharedState.path, reason: sharedState.reason ?? '' })}
</p>
) : null}
{!isLoading && sharedActions.length > 0 && sharedState ? (
<div className={cn('space-y-0 pb-1.5', PROJECT_SETTINGS_CONTROL_WIDTH)}>
<p className="typography-meta text-muted-foreground">
{t('settings.projects.shared.actionsFromRepo', { path: sharedState.path })}
</p>
{sharedActions.map((action) => {
const sharedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
const sharedIconName = PROJECT_ACTION_ICON_MAP[sharedIconKey] || 'play';
const hidden = hiddenSharedIds.includes(action.id);
return (
<div key={action.id} className="flex items-center gap-2 py-1">
<Icon name={sharedIconName} className={cn('h-4 w-4 shrink-0 text-muted-foreground', hidden && 'opacity-50')} />
<span className={cn('typography-ui-label truncate', hidden ? 'text-muted-foreground' : 'text-foreground')}>{action.name}</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-muted-foreground bg-[var(--surface-subtle)]">
{hidden ? t('settings.projects.shared.hiddenBadge') : t('settings.projects.shared.badge')}
</span>
<span className="min-w-0 flex-1 typography-meta font-mono text-muted-foreground truncate">{action.command}</span>
<Button type="button" variant="ghost" size="xs" className="!font-normal shrink-0" disabled={isSharing} title={hidden ? t('settings.projects.shared.actions.showTitle') : t('settings.projects.shared.actions.hideTitle')} onClick={() => void setSharedActionHidden(action.id, !hidden)}>
{hidden ? t('settings.projects.shared.actions.show') : t('settings.projects.shared.actions.hide')}
</Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal shrink-0" disabled={isSharing} title={t('settings.projects.shared.actions.makePersonalTitle')} onClick={() => void makeActionPersonal(action)}>
{t('settings.projects.shared.actions.makePersonal')}
</Button>
</div>
);
})}
</div>
) : null}
{isLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.loading')}</p>
) : actions.length === 0 ? (
) : actions.length === 0 && sharedActions.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.empty')}</p>
) : (
) : actions.length === 0 ? null : (
<div className={cn('space-y-0', PROJECT_SETTINGS_CONTROL_WIDTH)}>
{actions.map((action) => {
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
@@ -280,6 +403,19 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
</div>
</CollapsibleTrigger>
{action.name.trim() && action.command.trim() ? (
<Button
type="button"
variant="ghost"
size="xs"
className="!font-normal shrink-0"
disabled={isSharing || hasChanges}
title={hasChanges ? t('settings.projects.shared.actions.shareAfterSave') : t('settings.projects.shared.actions.shareTitle', { path: sharedState?.path ?? '.openchamber/project.json' })}
onClick={() => void shareAction(action)}
>
{t('settings.projects.shared.actions.share')}
</Button>
) : null}
<Button
type="button"
variant="ghost"
@@ -3,6 +3,7 @@ import { WorktreeSectionContent } from '@/components/sections/openchamber/Worktr
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
import { ProjectGitProvidersSection } from '@/components/sections/projects/ProjectGitProvidersSection';
import { ProjectIdentityFields } from '@/components/sections/projects/ProjectIdentityFields';
import { SharedProjectConfigSection } from '@/components/sections/projects/SharedProjectConfigSection';
import {
useProjectIdentityForm,
type ProjectIdentitySaveData,
@@ -51,6 +52,7 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
<ProjectGitProvidersSection projectRef={projectRef} />
<ProjectActionsSection projectRef={projectRef} />
{showWorktrees ? <WorktreeSectionContent projectRef={projectRef} /> : null}
<SharedProjectConfigSection projectRef={projectRef} />
</div>
);
};
@@ -0,0 +1,110 @@
import React from 'react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
import { SettingsFieldRow } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import {
getProjectSetup,
updateSharedProjectSetup,
type ProjectRef,
type ProjectSetup,
} from '@/lib/openchamberConfig';
type SharedProjectConfigSectionProps = {
projectRef: ProjectRef;
};
/**
* The team's shared file for this project: where it is, whether it could be
* read, and the shared plans folder. Sharing individual items happens next to the items themselves
* (actions, setup commands, starters); this block never creates the file on
* its own except when a plans folder is set.
*/
export const SharedProjectConfigSection: React.FC<SharedProjectConfigSectionProps> = ({ projectRef }) => {
const { t } = useI18n();
const [setup, setSetup] = React.useState<ProjectSetup | null>(null);
const [plansDirDraft, setPlansDirDraft] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
void getProjectSetup(projectRef).then((next) => {
if (cancelled) return;
setSetup(next);
setPlansDirDraft(next.shared.plansDir ?? '');
});
return () => {
cancelled = true;
};
}, [projectRef]);
const savePlansDir = React.useCallback(async () => {
if (!setup) return;
const next = plansDirDraft.trim();
if (next === (setup.shared.plansDir ?? '')) return;
setIsSaving(true);
try {
const saved = await updateSharedProjectSetup(projectRef, { plansDir: next || null });
if (!saved) {
toast.error(t('settings.projects.shared.toast.shareFailed'));
setPlansDirDraft(setup.shared.plansDir ?? '');
return;
}
setSetup(saved);
setPlansDirDraft(saved.shared.plansDir ?? '');
} finally {
setIsSaving(false);
}
}, [plansDirDraft, projectRef, setup, t]);
if (!setup) {
return null;
}
const status = setup.shared.status === 'invalid'
? t('settings.projects.shared.invalid', { path: setup.shared.path, reason: setup.shared.reason ?? '' })
: setup.shared.status === 'ok'
? t('settings.projects.shared.status.ok')
: t('settings.projects.shared.status.missing');
return (
<ProjectSettingsSubsection
title={t('settings.projects.shared.title')}
info={t('settings.projects.shared.description')}
settingsItem="projects.shared"
>
<SettingsFieldRow label={t('settings.projects.shared.file')}>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-foreground">{setup.shared.path}</span>
<span className={setup.shared.status === 'invalid' ? 'typography-meta text-[var(--status-warning)]' : 'typography-meta text-muted-foreground'}>
{status}
</span>
</div>
</SettingsFieldRow>
<SettingsFieldRow
label={t('settings.projects.shared.plansDir')}
info={t('settings.projects.shared.plansDirInfo')}
settingsItem="projects.shared.plansDir"
>
<Input
value={plansDirDraft}
onChange={(event) => setPlansDirDraft(event.target.value)}
onBlur={() => void savePlansDir()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
placeholder={t('settings.projects.shared.plansDirPlaceholder')}
aria-label={t('settings.projects.shared.plansDirAria')}
disabled={isSaving}
className="h-8 rounded-md px-3 font-mono text-xs"
/>
</SettingsFieldRow>
</ProjectSettingsSubsection>
);
};
@@ -70,7 +70,7 @@ import {
} from '@/lib/desktopHosts';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { loadDesktopSettings } from '@/lib/persistence';
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
const randomPort = (): number => {
@@ -354,20 +354,7 @@ const resolvePairingServerUrl = async (): Promise<string> => {
return fallback;
}
let response: Response;
try {
response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
} catch {
return fallback;
}
if (!response.ok) return fallback;
const settings = (await response.json().catch(() => null)) as null | {
desktopLanAccessActive?: unknown;
};
const settings = await loadDesktopSettings();
if (settings?.desktopLanAccessActive !== true) {
return fallback;
}

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