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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+164
@@ -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('<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></details></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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user