Merge remote-tracking branch 'origin/main' into feat/german-locale
This commit is contained in:
@@ -833,10 +833,11 @@ function App({ apis }: AppProps) {
|
||||
if (bootView.screen === 'chooser') {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="first-launch"
|
||||
localAvailable={bootView.localAvailable !== false}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
onChooseRemote={() => {
|
||||
// Switch to remote tab - handled internally by OnboardingScreen
|
||||
@@ -854,13 +855,14 @@ function App({ apis }: AppProps) {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="recovery"
|
||||
recoveryVariant={recoveryVariant}
|
||||
recoveryHostUrl={hostUrl}
|
||||
recoveryHostLabel={undefined}
|
||||
localAvailable={bootView.localAvailable !== false}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
@@ -19,7 +19,11 @@ import { useSync } from '@/sync/use-sync';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager';
|
||||
import {
|
||||
listProjectWorktrees,
|
||||
partitionWorktreesByRegisteredProject,
|
||||
worktreeMapsEqual,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
@@ -175,7 +179,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(projects.map(async (project) => {
|
||||
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
@@ -187,7 +190,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
}
|
||||
@@ -195,12 +197,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projects, worktreesByProject);
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
||||
if (!worktreeMapsEqual(partitionedWorktreesByProject, currentByProject)) {
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
availableWorktrees: [...partitionedWorktreesByProject.values()].flat(),
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const IpadSidebarResizeHandle: React.FC<{
|
||||
side: 'left' | 'right';
|
||||
isResizing: boolean;
|
||||
ariaLabel: string;
|
||||
handleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
|
||||
<div
|
||||
// z-50 AND rendered after the panel content: panes bring their own
|
||||
// full-cover overlays at z-50 (the file editor, for one), and a handle
|
||||
// underneath them is simply not there for the finger.
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-50 w-6 cursor-col-resize touch-none',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
)}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={ariaLabel}
|
||||
{...handleProps}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 w-[3px] transition-colors',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
isResizing && 'bg-[var(--interactive-border)]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
+497
-2397
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
@@ -41,7 +42,7 @@ const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
|
||||
|
||||
type MobileChangesSurfaceProps = {
|
||||
/** When provided, the list header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
/** 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
|
||||
@@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
@@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
}, runtimeKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -539,7 +541,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border/50 px-4 pb-4 pt-3">
|
||||
<div className="shrink-0 border-t border-border/70 px-4 pb-4 pt-3">
|
||||
<CommitSection
|
||||
stagedCount={stagedChangeEntries.length}
|
||||
commitMessage={commitMessage}
|
||||
@@ -594,7 +596,7 @@ const MobileDiffDetail: React.FC<{
|
||||
|
||||
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-3 border-b border-border/50 px-3 text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/70 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export type MobileConnectionNotice = {
|
||||
kind: 'unreachable' | 'auth-expired';
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MobileConnectionWelcome: React.FC<{
|
||||
onConnected: () => void;
|
||||
/** Why the user landed here (failed cold-launch auto-connect) — shown as a banner. */
|
||||
notice?: MobileConnectionNotice | null;
|
||||
}> = ({ onConnected, notice = null }) => {
|
||||
const { t } = useI18n();
|
||||
const conn = useMobileConnection(onConnected);
|
||||
const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn;
|
||||
const [serverUrl, setServerUrl] = React.useState('');
|
||||
const [connectionName, setConnectionName] = React.useState('');
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
||||
// scanning is unavailable (web build) or the user asks for it.
|
||||
const [manualOpen, setManualOpen] = React.useState(() => !isQrScanSupported());
|
||||
// Which saved connection is being connected to, for the per-row spinner.
|
||||
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||
const [password, setPassword] = React.useState('');
|
||||
|
||||
const handleSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void conn.connect({ url: serverUrl, clientToken, label: connectionName });
|
||||
}, [clientToken, conn, connectionName, serverUrl]);
|
||||
|
||||
// Accept a pasted pairing link (openchamber://connect?...) in the URL field and
|
||||
// split it back into the server URL + token.
|
||||
const handleUrlChange = React.useCallback((value: string) => {
|
||||
if (/^openchamber:\/\//i.test(value.trim())) {
|
||||
const payload = parseConnectionPayload(value);
|
||||
if (payload) {
|
||||
if ('pairing' in payload) {
|
||||
void conn.redeemPairingConnection(payload.pairing);
|
||||
return;
|
||||
}
|
||||
setServerUrl(payload.url);
|
||||
if (payload.label) setConnectionName(payload.label);
|
||||
if (payload.clientToken) setClientToken(payload.clientToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setServerUrl(value);
|
||||
}, [conn]);
|
||||
|
||||
const handleScanQr = React.useCallback(async () => {
|
||||
if (scanAbortRef.current || isBusy) return;
|
||||
conn.setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
setIsCompletingScan(true);
|
||||
setServerUrl(result.url);
|
||||
if (result.label) setConnectionName(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
conn.setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
case 'invalid':
|
||||
conn.setError(t('mobile.connect.scan.invalid'));
|
||||
break;
|
||||
case 'unsupported':
|
||||
conn.setError(t('mobile.connect.scan.unsupported'));
|
||||
break;
|
||||
case 'failed':
|
||||
conn.setError(t('mobile.connect.scan.failed'));
|
||||
break;
|
||||
case 'cancelled':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, isBusy, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void conn.submitPassword(password);
|
||||
}, [conn, password]);
|
||||
|
||||
const cancelPassword = React.useCallback(() => {
|
||||
setPassword('');
|
||||
conn.cancelPassword();
|
||||
}, [conn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
|
||||
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
|
||||
<h1 className="typography-h2 text-foreground">{t('mobile.connect.welcome.title')}</h1>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full items-center gap-3 rounded-[18px] border border-[color-mix(in_srgb,var(--status-warning)_35%,transparent)] bg-[color-mix(in_srgb,var(--status-warning)_10%,transparent)] px-3.5 py-3"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,var(--status-warning)_16%,transparent)] text-[var(--status-warning)]">
|
||||
<Icon name={notice.kind === 'auth-expired' ? 'lock' : 'cloud-off'} className="size-[18px]" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 typography-small text-foreground">
|
||||
{notice.kind === 'auth-expired'
|
||||
? t('mobile.connect.notice.authExpired', { label: notice.label })
|
||||
: t('mobile.connect.notice.unreachable', { label: notice.label })}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingConnection ? (
|
||||
<form className="flex w-full flex-col gap-3" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="lock" className="size-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('mobile.connect.password.placeholder')}
|
||||
aria-label={t('mobile.connect.password.label')}
|
||||
type="password"
|
||||
autoFocus
|
||||
className={mobileConnectionInputClass}
|
||||
/>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
||||
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={cancelPassword}
|
||||
>
|
||||
{t('mobile.connect.cancelPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Primary path: scan the pairing QR from "Add a device" on the server. */}
|
||||
{qrScanSupported ? (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => void handleScanQr()}
|
||||
disabled={isScanning || isBusy}
|
||||
>
|
||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.scanQr')}
|
||||
</Button>
|
||||
<p className="px-2 text-center typography-small text-muted-foreground">
|
||||
{t('mobile.connect.welcome.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && !manualOpen ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
|
||||
{connections.length > 0 ? (
|
||||
<section className="flex w-full flex-col gap-2.5">
|
||||
<h2 className="text-center typography-micro uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{t('mobile.connect.saved.title')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||
{connections.map((connection) => {
|
||||
const isConnectingRow = connectingId === connection.id;
|
||||
return (
|
||||
<button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/70 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-70"
|
||||
onClick={() => {
|
||||
setConnectingId(connection.id);
|
||||
void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||
.finally(() => setConnectingId(null));
|
||||
}}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className={cn('block truncate typography-small', isConnectingRow ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{isConnectingRow
|
||||
? t('mobile.connect.connecting')
|
||||
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
{isConnectingRow
|
||||
? <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />
|
||||
: <Icon name="arrow-right-s" className="size-5 text-muted-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Manual URL entry, collapsed by default — most people pair by QR. */}
|
||||
<div className="flex w-full flex-col">
|
||||
{qrScanSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManualOpen((value) => !value)}
|
||||
aria-expanded={manualOpen}
|
||||
className="mx-auto flex items-center gap-1 rounded-full px-2 py-1 typography-small text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span>{t('mobile.connect.manual.toggle')}</span>
|
||||
<Icon name="arrow-down-s" className={cn('size-4 transition-transform duration-200', manualOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: manualOpen ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<form className="flex w-full flex-col gap-3 pt-3" onSubmit={handleSubmit}>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={serverUrl}
|
||||
onChange={(event) => handleUrlChange(event.target.value)}
|
||||
placeholder={t('mobile.connect.url.placeholder')}
|
||||
aria-label={t('mobile.connect.url.label')}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<input
|
||||
value={connectionName}
|
||||
onChange={(event) => setConnectionName(event.target.value)}
|
||||
placeholder={t('mobile.instances.label.placeholder')}
|
||||
aria-label={t('mobile.instances.label.label')}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={clientToken}
|
||||
onChange={(event) => setClientToken(event.target.value)}
|
||||
placeholder={t('mobile.connect.token.placeholder')}
|
||||
aria-label={t('mobile.connect.token.label')}
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
autoCapitalize="none"
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<p className="px-1 text-center typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" variant={qrScanSupported ? 'outline' : 'default'} size="lg" className="h-12 w-full" disabled={isBusy || isScanning || !serverUrl.trim()}>
|
||||
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.connectButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -169,7 +169,7 @@ export const MobileDeleteWorktreeDialog: React.FC<MobileDeleteWorktreeDialogProp
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl border border-border/50 px-3.5 py-3 text-left transition-colors',
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 px-3.5 py-3 text-left transition-colors',
|
||||
'hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
disabled && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React from 'react';
|
||||
import { File as PierreFile } from '@pierre/diffs/react';
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiClipboardLine,
|
||||
RiCloseLine,
|
||||
RiFileCopyLine,
|
||||
RiFolder3Fill,
|
||||
RiFolderOpenFill,
|
||||
RiLoader4Line,
|
||||
@@ -13,34 +10,28 @@ import {
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { PIERRE_RUNTIME_BASE_CSS } from '@/components/views/PierreDiffViewer';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// The full desktop file editor, loaded on demand — it's a heavy chunk and only
|
||||
// needed once a file is actually opened.
|
||||
const LazyFilesEditor = React.lazy(() =>
|
||||
import('@/components/views/FilesView').then((module) => ({ default: module.FilesView })),
|
||||
);
|
||||
|
||||
type MobileFilesRoute =
|
||||
| { type: 'browser'; directory: string }
|
||||
| { type: 'file'; path: string; returnDirectory: string };
|
||||
|
||||
const MAX_MOBILE_FILE_CHARS = 250_000;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getNameFromPath = (path: string): string => {
|
||||
@@ -77,24 +68,15 @@ const formatFileSize = (size?: number): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getImageSrc = (path: string): string => {
|
||||
if (path.toLowerCase().endsWith('.svg')) {
|
||||
return '';
|
||||
}
|
||||
return getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
|
||||
};
|
||||
|
||||
const isMarkdownFile = (path: string): boolean => /\.(md|mdx|markdown)$/i.test(path);
|
||||
const isJsonFile = (path: string): boolean => /\.(json|jsonc)$/i.test(path);
|
||||
|
||||
type MobileFilesSurfaceProps = {
|
||||
/** When provided, header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
/** When provided, the header gets a close X that calls this. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const [route, setRoute] = React.useState<MobileFilesRoute>(() => ({ type: 'browser', directory: root }));
|
||||
const [entries, setEntries] = React.useState<FileListEntry[]>([]);
|
||||
@@ -103,9 +85,6 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [searchResults, setSearchResults] = React.useState<FileSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [fileContent, setFileContent] = React.useState('');
|
||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const directoryLoadRequestIdRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -177,79 +156,64 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
};
|
||||
}, [files, query, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'file') return;
|
||||
setFileContent('');
|
||||
setFileError(null);
|
||||
|
||||
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.readFile) {
|
||||
setFileError(t('mobile.files.error.readUnavailable'));
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
void files.readFile(route.path)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
|
||||
? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
|
||||
: result.content);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoadingFile(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, route, t]);
|
||||
|
||||
const openDirectory = (directory: string) => {
|
||||
setQuery('');
|
||||
setRoute({ type: 'browser', directory });
|
||||
};
|
||||
|
||||
const openFile = (path: string) => {
|
||||
// FilesView (editor-only) reads its target from the files-view tabs store.
|
||||
setSelectedPath(root, path);
|
||||
setRoute({ type: 'file', path, returnDirectory: currentDirectory || root });
|
||||
};
|
||||
|
||||
const handleCopyPath = async (path: string) => {
|
||||
const result = await copyTextToClipboard(path);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.pathCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.contentCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
// Chat tool rows (read/skill/edit) stage a pending file focus/navigation in
|
||||
// the UI store — the same channel desktop's context panel consumes. Route
|
||||
// straight to the editor for targets inside this workspace; the editor
|
||||
// itself consumes pendingFileNavigation to jump to the requested line.
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
|
||||
React.useEffect(() => {
|
||||
const target = normalizePath(pendingFileNavigation?.path ?? pendingFileFocusPath ?? '');
|
||||
if (!target || !root) return;
|
||||
if (target !== root && !target.startsWith(`${root}/`)) return;
|
||||
setSelectedPath(root, target);
|
||||
setRoute({ type: 'file', path: target, returnDirectory: root });
|
||||
if (pendingFileFocusPath) useUIStore.getState().setPendingFileFocusPath(null);
|
||||
}, [pendingFileFocusPath, pendingFileNavigation, root, setSelectedPath]);
|
||||
|
||||
if (!root) {
|
||||
return <MobileFilesState message={t('mobile.files.empty.noDirectory')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'file') {
|
||||
// Full desktop file editor (toolbar, dirty/save, wrap, search, md/html
|
||||
// preview, open-file tabs) — FilesView is already mobile-aware (keyboard
|
||||
// nudge, touch menus); this host only adds the back row.
|
||||
return (
|
||||
<MobileFileDetail
|
||||
path={route.path}
|
||||
content={fileContent}
|
||||
error={fileError}
|
||||
isLoading={isLoadingFile}
|
||||
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
onCopyPath={() => void handleCopyPath(route.path)}
|
||||
onCopyContent={() => void handleCopyContent()}
|
||||
/>
|
||||
<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 border-b border-border/70 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(route.path)}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={<MobileFilesState loading message={t('filesView.state.loading')} />}>
|
||||
<LazyFilesEditor mode="editor-only" />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,7 +284,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
) : query.trim() ? (
|
||||
<MobileSearchResults results={visibleSearchResults} isSearching={isSearching} onOpenFile={openFile} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[var(--surface-elevated)]">
|
||||
{entries.length === 0 && !isLoadingDirectory ? (
|
||||
<div className="px-4 py-8 text-center typography-body text-muted-foreground">{t('mobile.files.empty.directory')}</div>
|
||||
) : null}
|
||||
@@ -350,7 +314,7 @@ const MobileFileRow: React.FC<{
|
||||
}> = ({ name, path, directory, meta, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/30 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/70 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={onClick}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -375,7 +339,7 @@ const MobileSearchResults: React.FC<{
|
||||
if (isSearching) return <MobileFilesState loading message={t('common.loading')} />;
|
||||
if (results.length === 0) return <MobileFilesState message={t('mobile.files.search.empty')} />;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[var(--surface-elevated)]">
|
||||
{results.map((result) => (
|
||||
<MobileFileRow
|
||||
key={result.path}
|
||||
@@ -390,139 +354,6 @@ const MobileSearchResults: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileDetail: React.FC<{
|
||||
path: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
onBack: () => void;
|
||||
onCopyPath: () => void;
|
||||
onCopyContent: () => void;
|
||||
}> = ({ path, content, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
|
||||
const { t } = useI18n();
|
||||
const imageAuthKey = isImageFile(path) && !path.toLowerCase().endsWith('.svg') ? path : '';
|
||||
const [imageAuthReadyKey, setImageAuthReadyKey] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!imageAuthKey) {
|
||||
setImageAuthReadyKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setImageAuthReadyKey('');
|
||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||
.then((token) => {
|
||||
if (!cancelled && token) setImageAuthReadyKey(imageAuthKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imageAuthKey]);
|
||||
|
||||
const imageAuthLoading = Boolean(imageAuthKey && imageAuthReadyKey !== imageAuthKey);
|
||||
const imageSrc = imageAuthLoading ? '' : getImageSrc(path);
|
||||
|
||||
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-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
||||
</div>
|
||||
{!isImageFile(path) ? (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyPath} aria-label={t('mobile.files.copyPathAria')}>
|
||||
<RiClipboardLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isLoading || imageAuthLoading ? (
|
||||
<MobileFilesState loading message={t('filesView.state.loading')} />
|
||||
) : error ? (
|
||||
<MobileFilesState message={error} />
|
||||
) : isImageFile(path) && imageSrc ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={imageSrc} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : isImageFile(path) ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : (
|
||||
<MobileTextFile path={path} content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, content }) => {
|
||||
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
|
||||
const lightTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||
[availableThemes, lightThemeId],
|
||||
);
|
||||
const darkTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true),
|
||||
[availableThemes, darkThemeId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
}, [darkTheme, lightTheme]);
|
||||
|
||||
const pierreTheme = React.useMemo(
|
||||
() => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }),
|
||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
||||
);
|
||||
|
||||
if (isMarkdownFile(path)) {
|
||||
return (
|
||||
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
|
||||
<SimpleMarkdownRenderer content={content} enableFileReferences={false} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
if (isJsonFile(path)) {
|
||||
return <JsonTreeView jsonString={content} className="h-full overflow-auto" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-auto bg-[var(--syntax-base-background)]">
|
||||
<PierreFile
|
||||
file={{
|
||||
name: getNameFromPath(path),
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(path) || undefined,
|
||||
}}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: 'wrap',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
unsafeCSS: PIERRE_RUNTIME_BASE_CSS,
|
||||
}}
|
||||
className="block min-h-full w-full"
|
||||
style={{ minHeight: '100%' }}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Enter-slide duration. Heavy content is revealed when this transition actually
|
||||
// ends (transitionend); this also feeds the fallback timer.
|
||||
const ENTER_DURATION_MS = 200;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileFullscreenSurfaceProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** If true, leave Escape available to nested content instead of dismissing the surface. */
|
||||
disableEscapeDismiss?: boolean;
|
||||
/** If true, render no header and let the child render its own (with its own back button). */
|
||||
headerless?: boolean;
|
||||
/** Drop the header's bottom divider (quiet single-page surfaces). */
|
||||
noHeaderBorder?: boolean;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* `dialog` packs the same surface into a centered card over a scrim instead
|
||||
* of covering the app. Tablets use it: a settings or instances page stretched
|
||||
* across a 13" screen is mostly empty space, and losing the chat entirely for
|
||||
* an app-level page is a heavier context switch than the content deserves.
|
||||
*/
|
||||
variant?: 'fullscreen' | 'dialog';
|
||||
/**
|
||||
* What the dialog centers on. App-level pages (settings, instances) belong to
|
||||
* the whole window; content that came out of the chat column stays with it.
|
||||
*/
|
||||
dialogAlign?: 'chat' | 'app';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** Fullscreen overlay surface for the phone layout: covers the whole app
|
||||
(including the header), slides in from the right like a navigation push,
|
||||
and closes via the header back arrow, Escape, or the Android back button. */
|
||||
export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
disableEscapeDismiss = false,
|
||||
headerless = false,
|
||||
noHeaderBorder = false,
|
||||
ariaLabel,
|
||||
variant = 'fullscreen',
|
||||
dialogAlign = 'chat',
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [contentReady, setContentReady] = React.useState(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
|
||||
// The parent passes a fresh inline onClose on every render; if the effect depended
|
||||
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
|
||||
// refocus the first element — stealing focus from whatever input the user is in
|
||||
// and collapsing the keyboard mid-edit.
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setEntered(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Defer mounting heavy children until the enter slide finishes, so the
|
||||
// animation stays smooth instead of competing with a large content render.
|
||||
// Primary trigger is the slide's transitionend (below); this is just a
|
||||
// fallback in case it never fires (reduced motion / interrupted transition).
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !disableEscapeDismiss) {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [disableEscapeDismiss, open]);
|
||||
|
||||
if (!open || !rootRef.current) return null;
|
||||
|
||||
const isDialog = variant === 'dialog';
|
||||
|
||||
const surface = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'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',
|
||||
)}
|
||||
style={isDialog ? {
|
||||
// Scale/fade instead of the push slide: the card is not a navigation
|
||||
// step, and a settled `transform: none` keeps it off its own
|
||||
// compositing layer (iOS clips those to the safe-area viewport).
|
||||
opacity: entered ? 1 : 0,
|
||||
transform: entered ? 'none' : 'scale(0.97)',
|
||||
transition: `opacity ${ENTER_DURATION_MS}ms ease-out, transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
} : {
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Push-style enter: slide in from the right edge; settled state drops
|
||||
// the transform entirely so the surface isn't kept on a compositing
|
||||
// layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
}}
|
||||
onTransitionEnd={(event) => {
|
||||
// Reveal content exactly when the enter transition ends — not on a fixed timer.
|
||||
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||
setContentReady(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!headerless ? (
|
||||
<header
|
||||
className={cn(
|
||||
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3',
|
||||
!noHeaderBorder && 'border-b border-border/70',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{contentReady ? (
|
||||
<div className="h-full" style={{ animation: 'oc-surface-content-in 200ms ease-out' }}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!isDialog) return createPortal(surface, rootRef.current);
|
||||
|
||||
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"
|
||||
style={{
|
||||
background: 'rgb(0 0 0 / 0.45)',
|
||||
opacity: entered ? 1 : 0,
|
||||
// 'chat' matches every other mobile overlay (the sessions sidebar keeps
|
||||
// its width); 'app' ignores the panels and centers on the window.
|
||||
paddingLeft: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-left, 0px))' : '1rem',
|
||||
paddingRight: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-right, 0px))' : '1rem',
|
||||
paddingTop: 'max(1rem, var(--oc-safe-area-top, 0px))',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex w-full max-w-[720px] justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
{surface}
|
||||
</div>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
|
||||
import { MobileSessionMetadataButton } from './MobileSessionMetadata';
|
||||
import { MobileSessionSwitcher } from './MobileSessionSwitcher';
|
||||
|
||||
export const MobileHeader: React.FC<{
|
||||
onOpenSessions: () => void;
|
||||
/** Opens the right workspace drawer (Changes / Files / Terminal / Notes / MCP). */
|
||||
onOpenWorkspace: () => void;
|
||||
/** Tablet: size the title trigger to its text instead of the free width, so
|
||||
a wide header doesn't turn the switcher into a full-width tap target. */
|
||||
compactTitle?: boolean;
|
||||
}> = ({ onOpenSessions, onOpenWorkspace, compactTitle = false }) => {
|
||||
const { t } = useI18n();
|
||||
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
const titleRef = React.useRef<HTMLButtonElement>(null);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]),
|
||||
);
|
||||
const effectiveDirectory = currentSessionDirectory || currentDirectory;
|
||||
const currentSession = useSession(currentSessionId, effectiveDirectory || undefined);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
|
||||
const sessionTitle = currentSession?.title?.trim();
|
||||
// Single-line title, desktop-style: session title, or the "New session"
|
||||
// placeholder on the draft screen. No project/branch metadata line.
|
||||
const primaryLabel = sessionTitle
|
||||
|| (currentSessionId ? t('mobile.sessions.untitled') : t('sessions.switcher.draftTitle'));
|
||||
|
||||
React.useEffect(() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
}, [currentSessionId, effectiveDirectory]);
|
||||
|
||||
const handleOpenSessions = React.useCallback(() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenSessions();
|
||||
}, [onOpenSessions]);
|
||||
|
||||
// The two header popovers are mutually exclusive.
|
||||
const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => {
|
||||
setMetadataOpen((current) => {
|
||||
const next = typeof value === 'function' ? value(current) : value;
|
||||
if (next) setSwitcherOpen(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSwitcher = React.useCallback(() => {
|
||||
setSwitcherOpen((current) => {
|
||||
const next = !current;
|
||||
if (next) setMetadataOpen(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="oc-mobile-header relative z-30 flex shrink-0 items-center gap-1 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={handleOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="list-unordered" className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Session title doubles as the recent-sessions switcher trigger. */}
|
||||
<button
|
||||
ref={titleRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
compactTitle ? 'shrink' : 'flex-1',
|
||||
)}
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={switcherOpen}
|
||||
onClick={toggleSwitcher}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="block min-w-0 truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{/* Discoverability: the chevron marks the title as a disclosure
|
||||
trigger and flips while the switcher is open. */}
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150',
|
||||
switcherOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Compact title: this takes the leftover width so the trailing
|
||||
controls stay pinned to the right edge. */}
|
||||
{compactTitle ? <div className="min-w-0 flex-1" /> : null}
|
||||
|
||||
<MobileSessionMetadataButton
|
||||
open={metadataOpen}
|
||||
onOpenChange={handleMetadataOpenChange}
|
||||
currentSessionId={currentSessionId}
|
||||
effectiveDirectory={effectiveDirectory}
|
||||
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<MobileSessionSwitcher
|
||||
open={switcherOpen}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
anchorRef={titleRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export const MobileInstancesSurface: React.FC<{
|
||||
onConnect: () => void;
|
||||
onActiveConnectionDeleted: () => void;
|
||||
}> = ({ onActiveConnectionDeleted, onConnect }) => {
|
||||
const { t } = useI18n();
|
||||
const conn = useMobileConnection(onConnect);
|
||||
const {
|
||||
connections, isBusy, isPasswordBusy, error, pendingConnection,
|
||||
connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError,
|
||||
} = conn;
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null;
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [label, setLabel] = React.useState('');
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// The manual add/edit form is hidden until asked for — the sheet leads with
|
||||
// the list of instances (with live status), not a wall of inputs.
|
||||
const [formOpen, setFormOpen] = React.useState(false);
|
||||
// Which row is being connected to, for the per-row spinner.
|
||||
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||
|
||||
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
|
||||
// an effect keyed on the derived connection object. With an effect, any churn of the
|
||||
// connections list re-fires it and overwrites what the user is typing — the keyboard
|
||||
// "resets" mid-edit. Imperative population is immune to that.
|
||||
const resetForm = React.useCallback(() => {
|
||||
setEditingId(null);
|
||||
setUrl('');
|
||||
setLabel('');
|
||||
setClientToken('');
|
||||
setError(null);
|
||||
setFormOpen(false);
|
||||
}, [setError]);
|
||||
|
||||
const saveInstance = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
// The id is what makes this an EDIT: saveConnection uses it to preserve the
|
||||
// existing relay/https candidates (and the Keychain token they key) instead
|
||||
// of rebuilding the instance from the single URL field.
|
||||
void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => {
|
||||
if (saved) resetForm();
|
||||
});
|
||||
}, [clientToken, editingId, label, resetForm, saveConnection, url]);
|
||||
|
||||
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
|
||||
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
|
||||
const handleScanInstance = React.useCallback(async () => {
|
||||
if (scanAbortRef.current) return;
|
||||
setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
// Legacy token QR: prefill the manual form for review before saving.
|
||||
setUrl(result.url);
|
||||
if (result.label) setLabel(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
setFormOpen(true);
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
case 'invalid':
|
||||
setError(t('mobile.connect.scan.invalid'));
|
||||
break;
|
||||
case 'unsupported':
|
||||
setError(t('mobile.connect.scan.unsupported'));
|
||||
break;
|
||||
case 'failed':
|
||||
setError(t('mobile.connect.scan.failed'));
|
||||
break;
|
||||
case 'cancelled':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, setError, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void submitPassword(password);
|
||||
}, [password, submitPassword]);
|
||||
|
||||
const cancelPasswordPrompt = React.useCallback(() => {
|
||||
setPassword('');
|
||||
cancelPassword();
|
||||
}, [cancelPassword]);
|
||||
|
||||
// Two-step delete (mirrors the session sheet): the trash icon arms the row, a
|
||||
// second tap on the destructive button confirms, the X disarms. No hover relied on.
|
||||
const toggleConfirmDelete = React.useCallback((id: string) => {
|
||||
setConfirmingDeleteId((current) => (current === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const confirmDelete = React.useCallback((id: string) => {
|
||||
setConfirmingDeleteId(null);
|
||||
if (editingId === id) resetForm();
|
||||
// Removing the ACTIVE instance — or the LAST one — must drop the user back
|
||||
// to the connect screen instead of leaving them in a stale, unbacked UI.
|
||||
const wasLast = connections.length === 1;
|
||||
void removeConnection(id).then((removed) => {
|
||||
if (!removed) return;
|
||||
if (wasLast || isActiveRuntimeConnection(removed)) {
|
||||
onActiveConnectionDeleted();
|
||||
}
|
||||
});
|
||||
}, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]);
|
||||
|
||||
const inputClass = mobileConnectionInputClass;
|
||||
|
||||
if (pendingConnection) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<form className="flex-1 overflow-y-auto px-5 py-4" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="lock" className="size-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('mobile.connect.password.placeholder')}
|
||||
aria-label={t('mobile.connect.password.label')}
|
||||
type="password"
|
||||
autoFocus
|
||||
className={inputClass}
|
||||
/>
|
||||
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
||||
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="w-full" onClick={cancelPasswordPrompt}>
|
||||
{t('mobile.connect.cancelPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-6">
|
||||
{connections.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||
{connections.map((connection) => {
|
||||
const confirming = confirmingDeleteId === connection.id;
|
||||
const isActive = isActiveRuntimeConnection(connection);
|
||||
const isConnectingRow = connectingId === connection.id;
|
||||
// Status line: the active instance says HOW it is connected right
|
||||
// now (direct vs relay); others show their address.
|
||||
const statusText = isConnectingRow
|
||||
? t('mobile.connect.connecting')
|
||||
: isActive
|
||||
? (isRelayModeActive() ? t('mobile.instances.status.connectedRelay') : t('mobile.instances.status.connectedDirect'))
|
||||
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge');
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className={cn(
|
||||
'flex items-center border-b border-border/70 transition-colors last:border-b-0',
|
||||
confirming && 'bg-[color-mix(in_srgb,var(--destructive)_8%,transparent)]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-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 disabled:opacity-60"
|
||||
onClick={() => {
|
||||
if (isActive) return;
|
||||
setConnectingId(connection.id);
|
||||
void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||
.finally(() => setConnectingId(null));
|
||||
}}
|
||||
disabled={(isBusy && !isConnectingRow) || confirming}
|
||||
>
|
||||
<span className="relative flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
{isActive ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-[var(--surface-elevated)] bg-[var(--status-success)]" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className={cn(
|
||||
'block truncate typography-small',
|
||||
isActive && !isConnectingRow ? 'text-[var(--status-success)]' : 'text-muted-foreground',
|
||||
)}>
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
{isConnectingRow ? <Icon name="loader-4" className="size-5 shrink-0 animate-spin text-muted-foreground" /> : null}
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
{confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.confirmDeleteAria', { label: connection.label })}
|
||||
className="flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-destructive-foreground transition-opacity active:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
|
||||
onClick={() => confirmDelete(connection.id)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-[18px]" />
|
||||
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
||||
</button>
|
||||
) : !connection.candidates.some((c) => c.kind === 'direct') ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.edit')}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setEditingId(connection.id);
|
||||
setUrl(connectionDisplayUrl(connection));
|
||||
setLabel(connection.label);
|
||||
setClientToken(connection.clientToken || '');
|
||||
setError(null);
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="edit" className="size-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={confirming
|
||||
? t('mobile.instances.cancelDeleteAria', { label: connection.label })
|
||||
: t('mobile.instances.deleteAria', { label: connection.label })}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => toggleConfirmDelete(connection.id)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name={confirming ? 'close' : 'delete-bin'} className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
|
||||
{t('mobile.connect.saved.empty')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Add actions: QR pairing is the primary path; the manual form stays
|
||||
hidden until asked for (or until a row's edit button opens it). */}
|
||||
{!formOpen && !editingConnection ? (
|
||||
<div className="space-y-2">
|
||||
{qrScanSupported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => void handleScanInstance()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||
{t('mobile.connect.scanQr')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant={qrScanSupported ? 'ghost' : 'outline'}
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => { setError(null); setFormOpen(true); }}
|
||||
>
|
||||
<Icon name="add" className="size-[18px]" />
|
||||
{t('mobile.instances.addManual')}
|
||||
</Button>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={saveInstance}>
|
||||
<div className="flex h-8 items-center justify-between gap-3 px-1">
|
||||
<h3 className="typography-ui-label text-foreground">
|
||||
{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}
|
||||
</h3>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={resetForm}>
|
||||
{t('mobile.instances.cancelEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.url.label')}</span>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder={t('mobile.connect.url.placeholder')}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.instances.label.label')}</span>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t('mobile.instances.label.placeholder')}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={clientToken}
|
||||
onChange={(event) => setClientToken(event.target.value)}
|
||||
placeholder={t('mobile.connect.token.placeholder')}
|
||||
autoCapitalize="none"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||
</label>
|
||||
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full">
|
||||
{editingConnection ? t('mobile.instances.saveEdit') : t('mobile.instances.saveNew')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import { useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
||||
|
||||
type MobileEditableProject = {
|
||||
id: string;
|
||||
@@ -72,7 +72,7 @@ const SortableWorktreeRow: React.FC<{
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-2xl border border-border/40 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
|
||||
'flex items-center gap-1 rounded-2xl border border-border/70 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
|
||||
isDragging && 'shadow-lg shadow-black/20',
|
||||
)}
|
||||
>
|
||||
@@ -218,12 +218,12 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileSurfaceShell
|
||||
<MobileFullscreenSurface
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onBack={onClose}
|
||||
title={t('projectEditDialog.title')}
|
||||
ariaLabel={t('projectEditDialog.title')}
|
||||
noHeaderBorder
|
||||
trailing={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -293,7 +293,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
aria-label={t('projectEditDialog.option.none')}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
color === null ? 'border-foreground' : 'border-border hover:border-border/80',
|
||||
color === null ? 'border-foreground' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -308,7 +308,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
title={c.label}
|
||||
className={cn(
|
||||
'size-9 rounded-xl border-2 transition-all',
|
||||
color === c.key ? 'border-foreground' : 'border-transparent hover:border-border',
|
||||
color === c.key ? 'border-foreground' : 'border-transparent hover:border-border/70',
|
||||
)}
|
||||
style={{ backgroundColor: c.cssVar, touchAction: 'manipulation' }}
|
||||
/>
|
||||
@@ -328,7 +328,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
aria-label={t('projectEditDialog.option.none')}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
icon === null ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
|
||||
icon === null ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -343,7 +343,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
title={i.label}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
icon === i.key ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
|
||||
icon === i.key ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -402,7 +402,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</MobileSurfaceShell>
|
||||
</MobileFullscreenSurface>
|
||||
|
||||
{project ? (
|
||||
<MobileDeleteWorktreeDialog
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const htmlBackground = {
|
||||
value: document.documentElement.style.getPropertyValue('background-color'),
|
||||
priority: document.documentElement.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
const bodyBackground = {
|
||||
value: document.body.style.getPropertyValue('background-color'),
|
||||
priority: document.body.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
// The app's reduced-transparency theme deliberately uses an !important
|
||||
// background. Use an inline important color while CameraX is behind the
|
||||
// WebView; the CSS minifier collapses `background: transparent` in a way
|
||||
// that does not reset that important background color on Android WebView.
|
||||
document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
|
||||
// startScan() places CameraX behind the WebView. OpenChamber has several
|
||||
// independent portal roots, so hiding only #root (or relying on inherited
|
||||
// visibility) can leave a sheet/sidebar painted over the preview. Opacity on
|
||||
// each top-level sibling is composited for its whole subtree and cannot be
|
||||
// overridden by descendants.
|
||||
const hidden = new Map<HTMLElement, { opacity: string; pointerEvents: string }>();
|
||||
const hideBodySibling = (node: Node) => {
|
||||
if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return;
|
||||
hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents });
|
||||
node.style.setProperty('opacity', '0');
|
||||
node.style.setProperty('pointer-events', 'none');
|
||||
};
|
||||
Array.from(document.body.children).forEach(hideBodySibling);
|
||||
const observer = new MutationObserver((records) => {
|
||||
records.forEach((record) => record.addedNodes.forEach(hideBodySibling));
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
hidden.forEach((previous, element) => {
|
||||
element.style.opacity = previous.opacity;
|
||||
element.style.pointerEvents = previous.pointerEvents;
|
||||
});
|
||||
if (htmlBackground.value) {
|
||||
document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('background-color');
|
||||
}
|
||||
if (bodyBackground.value) {
|
||||
document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority);
|
||||
} else {
|
||||
document.body.style.removeProperty('background-color');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') onCancel();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.connect.scanQr')}
|
||||
className="fixed inset-0 z-[1000] flex flex-col bg-transparent px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+24px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+24px)] text-foreground"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-6">
|
||||
<div className="aspect-square w-full max-w-72 rounded-[28px] border-2 border-foreground/90 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--surface-background)_18%,transparent)]" aria-hidden />
|
||||
<p className="max-w-sm rounded-[16px] border border-border/60 bg-background px-4 py-3 text-center typography-body text-foreground shadow-sm">
|
||||
{t('mobile.connect.welcome.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="lg" className="mx-auto min-h-12 w-full max-w-sm bg-background" onClick={onCancel}>
|
||||
<Icon name="close" className="size-[18px]" />
|
||||
{t('mobile.instances.cancelEdit')}
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileQrConnectionLoading: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
return createPortal(
|
||||
<div role="status" className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-5 bg-background px-6 text-foreground">
|
||||
<OpenChamberLogo width={96} height={96} isAnimated />
|
||||
<div className="flex items-center gap-2 typography-ui-label text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-[18px] animate-spin" />
|
||||
<span>{t('mobile.connect.connecting')}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,575 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import type { QuotaProviderId, UsageWindow } from '@/types';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
|
||||
const TABLET_METADATA_POPOVER_WIDTH = 380;
|
||||
|
||||
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
|
||||
if (!limit || typeof limit !== 'object') return undefined;
|
||||
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
|
||||
const getTokenCount = (value: unknown): number => (
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
);
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
type MobileUsageLimitRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
window: UsageWindow;
|
||||
};
|
||||
|
||||
type MobileUsageProviderGroup = {
|
||||
providerId: QuotaProviderId;
|
||||
providerName: string;
|
||||
rows: MobileUsageLimitRow[];
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
type ContextDisplay = {
|
||||
percentage: number;
|
||||
tokens: string;
|
||||
colorClass: string;
|
||||
} | null;
|
||||
|
||||
const getWindowValueClass = (window: UsageWindow): string => {
|
||||
const usedPercent = window.usedPercent;
|
||||
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground';
|
||||
if (usedPercent >= 80) return 'text-[var(--status-error)]';
|
||||
if (usedPercent >= 50) return 'text-[var(--status-warning)]';
|
||||
return 'text-foreground';
|
||||
};
|
||||
|
||||
const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => {
|
||||
const progressPct = clampPercent(percentage) ?? 0;
|
||||
const tone = resolveUsageTone(percentage);
|
||||
const progressColor = tone === 'critical'
|
||||
? 'var(--status-error)'
|
||||
: tone === 'warn'
|
||||
? 'var(--status-warning)'
|
||||
: 'var(--status-success)';
|
||||
const size = 18;
|
||||
const stroke = 3;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="size-[18px] -rotate-90"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progressPct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--interactive-border)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={progressColor}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - progressPct / 100)}
|
||||
className="transition-[stroke-dashoffset,stroke] duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const MetadataRow: React.FC<{
|
||||
icon?: IconName;
|
||||
iconNode?: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ icon, iconNode, label, children }) => (
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SessionMetadataOverlay: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
contextDisplay: ContextDisplay;
|
||||
usageGroups: MobileUsageProviderGroup[];
|
||||
usageDisplayMode: 'usage' | 'remaining';
|
||||
isUsageLoading: boolean;
|
||||
timeFormatPreference: TimeFormatPreference;
|
||||
}> = ({ open, onClose, anchorRef, contextDisplay, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => {
|
||||
const { t } = useI18n();
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — render a popover anchored to the metadata button instead.
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [anchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport. Anchor the popover in the
|
||||
// wrapper's own coordinate space — viewport-based lefts would double-count
|
||||
// the sidebar offset.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect || !wrapperRect) {
|
||||
setIpadAnchorLeft(null);
|
||||
return;
|
||||
}
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
const left = Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8),
|
||||
);
|
||||
setIpadAnchorLeft(left);
|
||||
};
|
||||
compute();
|
||||
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
||||
// toggle/resize, orientation change) — the header buttons move with it.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRender) return;
|
||||
setIsExiting(true);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsExiting(false);
|
||||
}, 140);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [open, shouldRender]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeIfOutside = (event: PointerEvent | WheelEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeIfOutside, true);
|
||||
document.addEventListener('wheel', closeIfOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeIfOutside, true);
|
||||
document.removeEventListener('wheel', closeIfOutside, true);
|
||||
};
|
||||
}, [anchorRef, onClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
className={cn(
|
||||
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{contextDisplay ? (
|
||||
<MetadataRow
|
||||
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
|
||||
label={t('mobile.header.metadata.context')}
|
||||
>
|
||||
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
||||
</span>
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
<MobileUsageLimits
|
||||
groups={usageGroups}
|
||||
displayMode={usageDisplayMode}
|
||||
isLoading={isUsageLoading}
|
||||
timeFormatPreference={timeFormatPreference}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes session-metadata-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes session-metadata-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileUsageLimits: React.FC<{
|
||||
groups: MobileUsageProviderGroup[];
|
||||
displayMode: 'usage' | 'remaining';
|
||||
isLoading: boolean;
|
||||
timeFormatPreference: TimeFormatPreference;
|
||||
}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => {
|
||||
const { t } = useI18n();
|
||||
const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used');
|
||||
|
||||
// First open often races the quota fetch (~2s) — show an explicit loading
|
||||
// row instead of collapsing to an empty overlay.
|
||||
if (groups.length === 0) {
|
||||
if (!isLoading) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-2.5 py-6 text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" aria-hidden />
|
||||
<span className="typography-ui-label">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name="timer" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">
|
||||
{t('mobile.header.metadata.usage')}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
|
||||
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
|
||||
<span className="truncate">{modeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
|
||||
{group.providerName}
|
||||
</span>
|
||||
{group.status && group.rows.length === 0 ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground">
|
||||
{group.status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{group.rows.map((row) => {
|
||||
const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent;
|
||||
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
|
||||
const resetLabel = formatQuotaResetLabel(
|
||||
row.window.resetAt,
|
||||
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
|
||||
timeFormatPreference,
|
||||
);
|
||||
return (
|
||||
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="truncate typography-ui-label text-muted-foreground">
|
||||
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
|
||||
</span>
|
||||
{resetLabel ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
|
||||
{metricLabel === '-' ? '' : metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{group.status && group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentSessionId,
|
||||
effectiveDirectory,
|
||||
isNewSessionDraftOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void;
|
||||
currentSessionId: string | null;
|
||||
effectiveDirectory: string | null;
|
||||
isNewSessionDraftOpen: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const metadataTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||
useConfigStore((state) => state.modelsMetadata.size);
|
||||
const savedSessionModel = useSelectionStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
|
||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
|
||||
useQuotaAutoRefresh();
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadQuotaSettings();
|
||||
}, [loadQuotaSettings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
preloadProviderLogos(dropdownProviderIds);
|
||||
}, [dropdownProviderIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || isQuotaLoading) return;
|
||||
const missingEnabledProvider = dropdownProviderIds.some((providerId) => (
|
||||
!quotaResults.some((result) => result.providerId === providerId)
|
||||
));
|
||||
if (!missingEnabledProvider) return;
|
||||
void fetchAllQuotas();
|
||||
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]);
|
||||
|
||||
const latestMessageModel = React.useMemo(() => {
|
||||
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
|
||||
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
|
||||
model?: { providerID?: string; modelID?: string };
|
||||
};
|
||||
if (message.role !== 'user') continue;
|
||||
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
|
||||
? message.model.providerID
|
||||
: undefined;
|
||||
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
|
||||
? message.model.modelID
|
||||
: undefined;
|
||||
if (providerID && modelID) return { providerID, modelID };
|
||||
}
|
||||
return null;
|
||||
}, [activeSessionMessages]);
|
||||
|
||||
const modelRef = latestMessageModel
|
||||
?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null)
|
||||
?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null);
|
||||
const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined;
|
||||
const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID);
|
||||
const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined;
|
||||
const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context')
|
||||
?? metadata?.limit?.context
|
||||
?? 0;
|
||||
const totalTokens = React.useMemo(() => {
|
||||
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
|
||||
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
|
||||
tokens?: {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
reasoning?: unknown;
|
||||
cache?: { read?: unknown; write?: unknown };
|
||||
};
|
||||
};
|
||||
if (message.role !== 'assistant' || !message.tokens) continue;
|
||||
const total = getTokenCount(message.tokens.input)
|
||||
+ getTokenCount(message.tokens.output)
|
||||
+ getTokenCount(message.tokens.reasoning)
|
||||
+ getTokenCount(message.tokens.cache?.read)
|
||||
+ getTokenCount(message.tokens.cache?.write);
|
||||
if (total > 0) return total;
|
||||
}
|
||||
return 0;
|
||||
}, [activeSessionMessages]);
|
||||
|
||||
const contextPercentage =
|
||||
!isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0
|
||||
? Math.min((totalTokens / contextLimit) * 100, 999)
|
||||
: null;
|
||||
const contextTokens = contextPercentage !== null
|
||||
? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}`
|
||||
: null;
|
||||
const contextColorClass =
|
||||
contextPercentage === null
|
||||
? ''
|
||||
: contextPercentage >= 90
|
||||
? 'text-[var(--status-error)]'
|
||||
: contextPercentage >= 75
|
||||
? 'text-[var(--status-warning)]'
|
||||
: 'text-[var(--status-success)]';
|
||||
const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens
|
||||
? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass }
|
||||
: null;
|
||||
|
||||
const usageGroups = React.useMemo<MobileUsageProviderGroup[]>(() => {
|
||||
const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result]));
|
||||
return QUOTA_PROVIDERS
|
||||
.filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id))
|
||||
.filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true)
|
||||
.map((providerMeta) => {
|
||||
const result = resultsByProvider.get(providerMeta.id)!;
|
||||
const rows: MobileUsageLimitRow[] = [];
|
||||
|
||||
for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) {
|
||||
rows.push({
|
||||
key: `window-${label}`,
|
||||
label: formatWindowLabel(label),
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
const modelEntries = Object.entries(result?.usage?.models ?? {});
|
||||
const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? [];
|
||||
const visibleModelEntries = providerSelectedModels.length > 0
|
||||
? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName))
|
||||
: modelEntries;
|
||||
for (const [modelName, modelUsage] of visibleModelEntries) {
|
||||
const entries = Object.entries(modelUsage.windows ?? {});
|
||||
if (entries.length === 0) continue;
|
||||
const [label, window] = entries[0];
|
||||
rows.push({
|
||||
key: `model-${modelName}-${label}`,
|
||||
label: formatWindowLabel(label),
|
||||
subtitle: getDisplayModelName(modelName),
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
const status = !result.ok && result.error
|
||||
? result.error
|
||||
: rows.length === 0
|
||||
? t('header.services.noRateLimitsReported')
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId: providerMeta.id,
|
||||
providerName: providerMeta.name,
|
||||
rows,
|
||||
status,
|
||||
};
|
||||
});
|
||||
}, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || usageGroups.length === 0) return;
|
||||
preloadProviderLogos(usageGroups.map((group) => group.providerId));
|
||||
}, [open, usageGroups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={metadataTriggerRef}
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
{/* Live context gauge doubles as the metadata trigger: filled by the
|
||||
session's context usage, an empty ring on a fresh draft. */}
|
||||
<ContextProgressIcon percentage={contextDisplay?.percentage ?? 0} />
|
||||
</button>
|
||||
<SessionMetadataOverlay
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
anchorRef={metadataTriggerRef}
|
||||
contextDisplay={contextDisplay}
|
||||
usageGroups={usageGroups}
|
||||
usageDisplayMode={quotaDisplayMode}
|
||||
isUsageLoading={isQuotaLoading}
|
||||
timeFormatPreference={timeFormatPreference}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
|
||||
const RECENT_SESSIONS_LIMIT = 10;
|
||||
/** Matches the metadata popover's width so both header dropdowns read as a pair. */
|
||||
const TABLET_POPOVER_WIDTH = 380;
|
||||
|
||||
const getSessionTitle = (session: Session, fallback: string): string =>
|
||||
session.title?.trim() || fallback;
|
||||
|
||||
/** One switcher row: live status (busy spinner / attention dot), title,
|
||||
"project · branch", compact time. Mirrors the desktop SessionSwitcherDropdown
|
||||
indicator conventions; no subsession chevrons on mobile by design. */
|
||||
const SwitcherRow: React.FC<{
|
||||
session: Session;
|
||||
meta: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}> = ({ session, meta, active, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const status = useGlobalSessionStatus(session.id);
|
||||
const unseenCount = useSessionUnseenCount(session.id);
|
||||
const statusType = status?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
||||
const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
|
||||
active && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className={cn('block truncate typography-ui-label', active ? 'text-primary' : 'text-foreground')}>
|
||||
{getSessionTitle(session, t('sessions.sidebar.session.untitled'))}
|
||||
</span>
|
||||
{meta ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{meta}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Activity sits on the right, before the time — no reserved left gutter. */}
|
||||
{isStreaming ? (
|
||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin text-primary" aria-hidden />
|
||||
) : showUnreadDot ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[var(--status-info)]" aria-hidden />
|
||||
) : null}
|
||||
{timeLabel ? (
|
||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** Recent-sessions popover under the mobile header, opened by tapping the
|
||||
session title. Same visual family as the metadata/usage overlay. */
|
||||
export const MobileSessionSwitcher: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
}> = ({ open, onClose, anchorRef }) => {
|
||||
const { t } = useI18n();
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — anchor a popover under the title instead. Mirror image of the
|
||||
// metadata/usage popover, which anchors to the ring on the right.
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [anchorLeft, setAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport — anchor in the wrapper's own
|
||||
// coordinate space (see SessionMetadataOverlay for the same reasoning).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect || !wrapperRect) {
|
||||
setAnchorLeft(null);
|
||||
return;
|
||||
}
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
setAnchorLeft(Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8),
|
||||
));
|
||||
};
|
||||
compute();
|
||||
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
||||
// toggle/resize, orientation change) — the header buttons move with it.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
|
||||
const items = useSwitcherItems(open || shouldRender, { maxParents: RECENT_SESSIONS_LIMIT });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Fresh authoritative snapshot on open — updated stamps re-sort recents
|
||||
// (see raiseSessionOrderingBaselines) while the cached list shows first.
|
||||
void refreshGlobalSessions();
|
||||
setShouldRender(true);
|
||||
setIsExiting(false);
|
||||
return;
|
||||
}
|
||||
if (!shouldRender) return;
|
||||
setIsExiting(true);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsExiting(false);
|
||||
}, 140);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [open, shouldRender]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeIfOutside = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', closeIfOutside, true);
|
||||
return () => document.removeEventListener('pointerdown', closeIfOutside, true);
|
||||
}, [anchorRef, onClose, open]);
|
||||
|
||||
const handleSelect = React.useCallback((session: Session) => {
|
||||
void setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
|
||||
onClose();
|
||||
}, [onClose, setCurrentSession]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center typography-small text-muted-foreground">
|
||||
{t('sessions.switcher.empty')}
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const session = item.node.session;
|
||||
const meta = [item.secondaryMeta?.projectLabel, item.secondaryMeta?.branchLabel]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return (
|
||||
<SwitcherRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
meta={meta}
|
||||
active={session.id === currentSessionId}
|
||||
onSelect={() => {
|
||||
if (item.projectId) setActiveProjectIdOnly(item.projectId);
|
||||
handleSelect(session);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes session-switcher-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes session-switcher-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react';
|
||||
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const DISMISS_THRESHOLD_PX = 90;
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Enter-slide duration. Heavy content is revealed when this transition actually
|
||||
// ends (transitionend); this also feeds the fallback timer.
|
||||
const ENTER_DURATION_MS = 100;
|
||||
// How far below its resting position the sheet starts the enter slide. Small
|
||||
// offset → a short "rise + fade" rather than a full slide up from the bottom.
|
||||
const ENTER_OFFSET_PX = 48;
|
||||
// Extra gap above the sheet (below the top safe area) so it doesn't sit flush
|
||||
// against the very top of the app.
|
||||
const TOP_GAP_PX = 8;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileSurfaceShellProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */
|
||||
onBack?: () => void;
|
||||
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
|
||||
disableSwipeDismiss?: boolean;
|
||||
/** If true, render only the drag handle and let the child render its own header. */
|
||||
headerless?: boolean;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
onBack,
|
||||
disableSwipeDismiss = false,
|
||||
headerless = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [contentReady, setContentReady] = React.useState(false);
|
||||
const [dragOffset, setDragOffset] = React.useState(0);
|
||||
const dragStartYRef = React.useRef<number | null>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
|
||||
// The parent passes a fresh inline onClose on every render; if the effect depended
|
||||
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
|
||||
// refocus the first element — stealing focus from whatever input the user is in
|
||||
// and collapsing the keyboard mid-edit.
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setMounted(false), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Defer mounting heavy children until the enter slide finishes, so the
|
||||
// animation stays smooth instead of competing with a large content render.
|
||||
// Primary trigger is the slide's transitionend (below); this is just a
|
||||
// fallback in case it never fires (reduced motion / interrupted transition).
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disableSwipeDismiss) return;
|
||||
dragStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
isDraggingRef.current = true;
|
||||
};
|
||||
|
||||
const handleDragMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current || dragStartYRef.current == null) return;
|
||||
const currentY = event.touches[0]?.clientY ?? dragStartYRef.current;
|
||||
const delta = currentY - dragStartYRef.current;
|
||||
setDragOffset(delta > 0 ? delta : 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
if (dragOffset >= DISMISS_THRESHOLD_PX) {
|
||||
setDragOffset(0);
|
||||
onClose();
|
||||
} else {
|
||||
setDragOffset(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || !rootRef.current) return null;
|
||||
|
||||
const leading = onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// When settled, use `none` (not translateY(0)) so the sheet isn't kept on a
|
||||
// compositing layer — that layer is clipped to the safe-area viewport on iOS,
|
||||
// leaving a scrim gap below it over the home-indicator inset.
|
||||
const visualTransform = !entered
|
||||
? `translateY(${ENTER_OFFSET_PX}px)`
|
||||
: dragOffset > 0
|
||||
? `translateY(${dragOffset}px)`
|
||||
: 'none';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||
// The opacity transition keeps the scrim on its own compositing layer,
|
||||
// which iOS Safari clips to the viewport — without it, a static scrim
|
||||
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
|
||||
// it still feels near-instant.
|
||||
'transition-opacity duration-200 ease-out',
|
||||
entered ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Sheet is a normal flex child — mirroring MobileOverlayPanel. */}
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
className="mt-auto flex min-h-0 w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground"
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onTransitionEnd={(event) => {
|
||||
// Reveal content exactly when the enter slide ends — not on a fixed timer.
|
||||
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||
setContentReady(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// Sized to leave the top safe area (plus a small gap) uncovered so the
|
||||
// scrim dims it and the sheet sits a few px below the very top.
|
||||
height: `calc(100% - var(--oc-safe-area-top, 0px) - ${TOP_GAP_PX}px)`,
|
||||
transform: visualTransform,
|
||||
transition: isDraggingRef.current
|
||||
? 'none'
|
||||
: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 select-none"
|
||||
onTouchStart={handleDragStart}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onTouchCancel={handleDragEnd}
|
||||
>
|
||||
<div className="flex items-center justify-center pt-2 pb-1">
|
||||
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
|
||||
</div>
|
||||
{!headerless ? (
|
||||
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{contentReady ? (
|
||||
<div className="h-full" style={{ animation: 'oc-surface-content-in 200ms ease-out' }}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
|
||||
const DRAWER_ROOT_ID = 'mobile-surface-root';
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Slightly long, decelerating slide — matches the sessions drawer so both
|
||||
// sides feel like the same piece of chrome.
|
||||
const ENTER_DURATION_MS = 320;
|
||||
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
export type MobileWorkspaceTab = 'changes' | 'files' | 'terminal' | 'notes' | 'mcp';
|
||||
|
||||
/** Quick MCP enable/disable toggles as a workspace pane, with its own slim
|
||||
action row (add server → settings, refresh) replacing the old fullscreen
|
||||
surface's header actions. */
|
||||
const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenMcpSettings }) => {
|
||||
const { t } = useI18n();
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const refreshMcpStatus = useMcpStore((state) => state.refresh);
|
||||
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
|
||||
|
||||
const refresh = () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
void Promise.all([
|
||||
refreshMcpStatus({ directory: currentDirectory || null, silent: true }),
|
||||
loadMcpConfigs({ force: true }),
|
||||
minSpinPromise,
|
||||
]).finally(() => setIsRefreshing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end gap-1 px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={onOpenMcpSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-5', isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP).
|
||||
|
||||
Two hosts, same content and same state:
|
||||
- `drawer` (default) covers the app and slides in from the right edge —
|
||||
the phone, and a tablet in portrait where a side panel would leave no
|
||||
usable chat column;
|
||||
- `panel` renders inline so the caller can size it as a real sidebar
|
||||
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). */
|
||||
export const MobileWorkspaceDrawer: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tab: MobileWorkspaceTab;
|
||||
onTabChange: (tab: MobileWorkspaceTab) => void;
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { path: string; title: string }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(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);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
const tabRef = React.useRef(tab);
|
||||
React.useEffect(() => {
|
||||
tabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
// Tabs the user has actually opened — their panes stay mounted afterwards.
|
||||
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setVisitedTabs((current) => {
|
||||
if (current.has(tab)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(tab);
|
||||
return next;
|
||||
});
|
||||
}, [open, tab]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
let root = document.getElementById(DRAWER_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = DRAWER_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
rootRef.current = root;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setVisible(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setVisible(false), ENTER_DURATION_MS + 40);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
// Only the full-cover drawer owns the page scroll; the inline panel sits
|
||||
// inside the shell and must leave the chat beside it scrollable.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
if (variant === 'drawer') document.body.style.overflow = 'hidden';
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// The terminal owns Escape (it goes to the PTY) — don't hijack it.
|
||||
if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
if (variant === 'drawer') document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, variant]);
|
||||
|
||||
if (variant === 'drawer' && !rootRef.current) return null;
|
||||
|
||||
const tabItems: SortableTabsStripItem[] = [
|
||||
{ id: 'changes', label: t('mobile.menu.changes'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'files', label: t('mobile.menu.files'), icon: <Icon name="file-text" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'terminal', label: t('mobile.menu.terminal'), icon: <Icon name="terminal" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'notes', label: t('contextRail.surface.notes'), icon: <Icon name="sticky-note" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: t('mobile.menu.mcp'), icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center">
|
||||
{/* Mounted only while shown; nonCompositedIndicator keeps the active
|
||||
pill off its own compositing layer — creating one inside the
|
||||
drawer's slide flickers in WKWebView. */}
|
||||
{visible ? (
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={tab}
|
||||
onSelect={(id) => onTabChange(id as MobileWorkspaceTab)}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
nonCompositedIndicator
|
||||
// Five tabs don't fit with labels — the active tab keeps
|
||||
// icon + label, the rest collapse to icons.
|
||||
inactiveTabsIconOnly
|
||||
className="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{/* Panes stay MOUNTED once visited (hidden when inactive/closed), so
|
||||
reopening the drawer lands exactly where the user left off — an
|
||||
open diff, an edited file, an attached terminal. */}
|
||||
{visitedTabs.has('changes') ? (
|
||||
<div
|
||||
// A newly requested per-file diff remounts the pane so
|
||||
// initialDiffPath 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}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('files') ? (
|
||||
<div className={cn('h-full', tab !== 'files' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<MobileFilesSurface />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('terminal') ? (
|
||||
<div className={cn('h-full', tab !== 'terminal' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<TerminalView visible={open && tab === 'terminal'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('notes') ? (
|
||||
<div className={cn('h-full', tab !== 'notes' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<ProjectContextPanel onActionComplete={onClose} onOpenPlan={onOpenPlan} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('mcp') ? (
|
||||
<div className={cn('h-full', tab !== 'mcp' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<McpWorkspacePane onOpenMcpSettings={onOpenMcpSettings} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'panel') {
|
||||
// The caller animates the width; the content itself is plain flow so it
|
||||
// never gets its own compositing layer (iOS clips those to the safe-area
|
||||
// viewport, which is exactly what the drawer's settled `transform: none`
|
||||
// avoids on the other host).
|
||||
return <div className="flex h-full min-h-0 flex-col bg-background text-foreground">{body}</div>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
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"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</section>,
|
||||
rootRef.current as HTMLElement,
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
@@ -107,6 +108,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
|
||||
|
||||
@@ -59,9 +58,10 @@ const execute = (intent: DeepLinkIntent): boolean => {
|
||||
return true;
|
||||
|
||||
case 'status':
|
||||
// The session status panel is store-backed (useUIStore.mobileSessionPanelOpen),
|
||||
// so it opens without a shell handler — like session/new-session.
|
||||
useUIStore.getState().setMobileSessionPanelOpen(true);
|
||||
// The old input-bar status panel is gone — recent sessions with statuses
|
||||
// now live in the sessions drawer, so route status links there.
|
||||
if (!handlers.openSessions) return false;
|
||||
handlers.openSessions();
|
||||
return true;
|
||||
|
||||
case 'view':
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
|
||||
export const IPAD_LEFT_SIDEBAR_WIDTH = 320;
|
||||
export const IPAD_RIGHT_SIDEBAR_WIDTH = 380;
|
||||
const IPAD_SIDEBAR_MIN_WIDTH = 280;
|
||||
const IPAD_SIDEBAR_MAX_WIDTH = 560;
|
||||
/** The workspace panel holds diffs, a file editor and a terminal, so it earns
|
||||
far more room than the sessions list ever needs. */
|
||||
export const IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH = 900;
|
||||
|
||||
/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop
|
||||
Sidebar (imperative styles during the drag, committed to state at the end),
|
||||
but with a finger-sized grab strip instead of a 3px hover handle. */
|
||||
export function useIpadSidebarResize(
|
||||
side: 'left' | 'right',
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
maxWidth: number = IPAD_SIDEBAR_MAX_WIDTH,
|
||||
) {
|
||||
const asideRef = React.useRef<HTMLElement | null>(null);
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
|
||||
if (!Number.isFinite(stored)) return defaultWidth;
|
||||
return Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
||||
});
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(width);
|
||||
const liveWidthRef = React.useRef<number | null>(null);
|
||||
const pointerIdRef = React.useRef<number | null>(null);
|
||||
|
||||
const clampWidth = React.useCallback((value: number) => (
|
||||
Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
|
||||
), [maxWidth]);
|
||||
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
aside.style.width = `${nextWidth}px`;
|
||||
aside.style.minWidth = `${nextWidth}px`;
|
||||
aside.style.maxWidth = `${nextWidth}px`;
|
||||
aside.style.setProperty('--oc-ipad-sidebar-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
pointerIdRef.current = event.pointerId;
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = width;
|
||||
liveWidthRef.current = width;
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
}, [width]);
|
||||
|
||||
const handlePointerMove = React.useCallback((event: React.PointerEvent) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const next = clampWidth(startWidthRef.current + (side === 'left' ? delta : -delta));
|
||||
if (liveWidthRef.current === next) return;
|
||||
liveWidthRef.current = next;
|
||||
applyLiveWidth(next);
|
||||
}, [applyLiveWidth, clampWidth, side]);
|
||||
|
||||
const handlePointerEnd = React.useCallback((event: React.PointerEvent) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const finalWidth = clampWidth(liveWidthRef.current ?? startWidthRef.current);
|
||||
pointerIdRef.current = null;
|
||||
liveWidthRef.current = null;
|
||||
setIsResizing(false);
|
||||
setWidth(finalWidth);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(finalWidth));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [clampWidth, storageKey]);
|
||||
|
||||
const handleProps = React.useMemo(() => ({
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerEnd,
|
||||
onPointerCancel: handlePointerEnd,
|
||||
}), [handlePointerDown, handlePointerEnd, handlePointerMove]);
|
||||
|
||||
return { asideRef, width, isResizing, handleProps };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Kills autocorrect/autocomplete on URL/token/password fields — mobile keyboards
|
||||
mangle those values otherwise. */
|
||||
export const mobileInputKeyboardProps = {
|
||||
autoComplete: 'off',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
} as const;
|
||||
|
||||
export const mobileConnectionInputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -40,6 +40,36 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('cancellation invalidates an in-flight password completion', async () => {
|
||||
const tracker = createMobilePasswordOperationTracker();
|
||||
const operation = tracker.begin();
|
||||
let resolveLogin: () => void = () => {
|
||||
throw new Error('Login was not started');
|
||||
};
|
||||
let switchedRuntime = false;
|
||||
const completion = new Promise<void>((resolve) => { resolveLogin = resolve; }).then(() => {
|
||||
if (tracker.isCurrent(operation)) switchedRuntime = true;
|
||||
});
|
||||
|
||||
tracker.cancel();
|
||||
resolveLogin();
|
||||
await completion;
|
||||
|
||||
expect(switchedRuntime).toBe(false);
|
||||
});
|
||||
|
||||
test('removes inline tokens only after each secure migration succeeds', async () => {
|
||||
const result = await migrateLegacyInlineTokenRecords([
|
||||
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
|
||||
{ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' },
|
||||
], async (url) => url.includes('ok.example'));
|
||||
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.records[0]).toEqual({ id: 'ok', url: 'http://ok.example', hasToken: true });
|
||||
expect(result.records[1]).toEqual({ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' });
|
||||
});
|
||||
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
@@ -72,6 +72,20 @@ const MOBILE_SECURE_TIMEOUT_MS = 3000;
|
||||
// feels instant instead of hanging for seconds.
|
||||
const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500;
|
||||
|
||||
export const createMobilePasswordOperationTracker = () => {
|
||||
let current = 0;
|
||||
return {
|
||||
begin: (): number => {
|
||||
current += 1;
|
||||
return current;
|
||||
},
|
||||
cancel: (): void => {
|
||||
current += 1;
|
||||
},
|
||||
isCurrent: (operation: number): boolean => operation === current,
|
||||
};
|
||||
};
|
||||
|
||||
export type MobileConnectionMode = 'direct' | 'relay';
|
||||
|
||||
// Persisted relay transport config. This is connection metadata, not a secret
|
||||
@@ -691,6 +705,30 @@ const deleteSecureToken = async (key: string): Promise<void> => {
|
||||
|
||||
// One-time migration: a legacy localStorage record on native might still carry an
|
||||
// inline `clientToken`. Move it into the secure store and strip the metadata.
|
||||
export const migrateLegacyInlineTokenRecords = async (
|
||||
records: unknown[],
|
||||
migrateToken: (url: string, token: string) => Promise<boolean>,
|
||||
): Promise<{ records: unknown[]; migrated: number; failed: number }> => {
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const next = await Promise.all(records.map(async (item) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
const record = item as Record<string, unknown>;
|
||||
const url = typeof record.url === 'string' ? record.url : null;
|
||||
const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : '';
|
||||
if (!url || !token) return item;
|
||||
if (!await migrateToken(url, token)) {
|
||||
failed += 1;
|
||||
return item;
|
||||
}
|
||||
migrated += 1;
|
||||
const { clientToken: _removed, ...metadata } = record;
|
||||
void _removed;
|
||||
return { ...metadata, hasToken: true };
|
||||
}));
|
||||
return { records: next, migrated, failed };
|
||||
};
|
||||
|
||||
const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined' || !isCapacitorApp()) return;
|
||||
let parsed: unknown;
|
||||
@@ -707,11 +745,20 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
&& Boolean((item as { clientToken: string }).clientToken.trim()));
|
||||
if (legacy.length === 0) return;
|
||||
logStorage('secure:migrate-start', { count: legacy.length });
|
||||
for (const { url, clientToken } of legacy) {
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
|
||||
const key = getConnectionStorageKey(url);
|
||||
if (!await writeSecureToken(key, token)) return false;
|
||||
return await readSecureToken(key) === token;
|
||||
});
|
||||
if (result.migrated > 0) {
|
||||
try {
|
||||
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records));
|
||||
} catch (error) {
|
||||
console.warn('[mobile-storage] failed to finalize secure token migration', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeConnections(readConnections());
|
||||
logStorage('secure:migrate-done', { count: legacy.length });
|
||||
logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed });
|
||||
};
|
||||
|
||||
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
|
||||
@@ -797,7 +844,12 @@ const probeConnectionCandidates = async (
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
|
||||
// With a bearer token, probe EXACTLY the way the runtime authenticates:
|
||||
// bearer-only, no cookies. A leftover valid oc_ui_session cookie in the
|
||||
// WebView otherwise answers "authenticated" for a revoked/expired token,
|
||||
// the probe passes, and the app dies later on bootstrap's bearer-only
|
||||
// requests. Cookie auth stays for the token-less (browser) flow.
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
||||
if (session?.status === 401) return { status: 'needs-login' };
|
||||
if (!session || (!session.ok && session.status !== 404)) continue;
|
||||
const status = await readSessionStatus(session);
|
||||
@@ -929,28 +981,45 @@ export const getAutoConnectTargetLabel = (): string | null => {
|
||||
// the runtime endpoint when reachable AND we already have a usable bearer token;
|
||||
// returns false — caller shows the connect screen — when there is no saved
|
||||
// instance, it's unreachable, or it needs a (re)login. No prompts or UI state.
|
||||
export const autoConnectLastInstance = async (): Promise<boolean> => {
|
||||
export type AutoConnectOutcome =
|
||||
| { status: 'connected' }
|
||||
/** No saved instance / no saved token — nothing to report to the user. */
|
||||
| { status: 'no-candidate' }
|
||||
| { status: 'unreachable'; label: string }
|
||||
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
|
||||
| { status: 'needs-login'; label: string };
|
||||
|
||||
export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> => {
|
||||
await migrateLegacyInlineTokens();
|
||||
const candidate = readConnections()[0]; // sorted most-recent-first
|
||||
if (!candidate) return false;
|
||||
if (!candidate) return { status: 'no-candidate' };
|
||||
|
||||
// The runtime transport needs a bearer token; only auto-connect when one is
|
||||
// already saved. A missing/expired token must go through the login UI.
|
||||
let token: string | undefined;
|
||||
if (isCapacitorApp()) {
|
||||
if (!candidate.hasToken) return false;
|
||||
if (!candidate.hasToken) {
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
token = await readSecureToken(secureTokenKeyOf(candidate));
|
||||
if (!token) return false;
|
||||
if (!token) {
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
} else {
|
||||
token = candidate.clientToken;
|
||||
if (!token) return false;
|
||||
if (!token) return { status: 'no-candidate' };
|
||||
}
|
||||
|
||||
const result = await probeConnectionCandidates(candidate.candidates, token);
|
||||
if (result.status !== 'ok') return false;
|
||||
// Fast probe: the cold-launch splash should decide in a couple of seconds,
|
||||
// not sit through the full connect timeouts on a dead LAN candidate. A slow
|
||||
// network that fails the fast probe still lands on the connect screen where
|
||||
// a manual tap retries with the full budget.
|
||||
const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true });
|
||||
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
|
||||
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
|
||||
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
|
||||
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
|
||||
return true;
|
||||
return { status: 'connected' };
|
||||
};
|
||||
|
||||
export const validateMobileConnectionSession = async (input: {
|
||||
@@ -972,7 +1041,9 @@ export const validateMobileConnectionSession = async (input: {
|
||||
const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions);
|
||||
if (!health?.ok) return false;
|
||||
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
|
||||
// Bearer-only when a token is present — see the probe note about stale
|
||||
// session cookies masking a revoked token.
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
||||
if (!session || (!session.ok && session.status !== 404)) return false;
|
||||
|
||||
const status = await readSessionStatus(session);
|
||||
@@ -1082,7 +1153,7 @@ export const isActiveRuntimeConnection = (connection: MobileSavedConnection): bo
|
||||
return Boolean(runtimeKey) && secureTokenKeyOf(connection) === runtimeKey;
|
||||
};
|
||||
|
||||
export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'no-connection';
|
||||
export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-login' | 'no-connection';
|
||||
|
||||
// App-resume re-probe: when the app wakes (Capacitor `isActive`), the network may
|
||||
// have changed while it slept, so re-select the active device's transport and
|
||||
@@ -1116,7 +1187,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
||||
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
if (better.status === 'needs-login') return 'unreachable';
|
||||
// The shared token was explicitly rejected — no transport will accept it.
|
||||
if (better.status === 'needs-login') return 'needs-login';
|
||||
|
||||
// 2. No better transport — is the current one still alive on its live channel?
|
||||
if (currentIndex >= 0) {
|
||||
@@ -1139,6 +1211,7 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
||||
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
if (fallback.status === 'needs-login') return 'needs-login';
|
||||
return 'unreachable';
|
||||
};
|
||||
|
||||
@@ -1280,6 +1353,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
|
||||
const connectionsRef = React.useRef(connections);
|
||||
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
|
||||
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
|
||||
|
||||
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
|
||||
connectionsRef.current = next;
|
||||
@@ -1463,6 +1537,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
|
||||
setError(null);
|
||||
beginBusy('password');
|
||||
const operation = passwordOperationRef.current.begin();
|
||||
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
|
||||
const { id, label, candidates } = pendingConnection;
|
||||
// A chosen relay transport owns an open tunnel; close it unless the switch
|
||||
// adopted it as the runtime tunnel.
|
||||
@@ -1473,6 +1549,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
// tunnel; cookies never cross it, so an issued bearer token is mandatory
|
||||
// there. `issueClientToken` mints the device's token in one round-trip.
|
||||
chosen = await establishLiveTransport(candidates);
|
||||
if (!isCurrentOperation()) return;
|
||||
if (!chosen) {
|
||||
setError(t('mobile.connect.error.unreachable'));
|
||||
return;
|
||||
@@ -1489,12 +1566,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const response = chosen.kind === 'relay'
|
||||
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
|
||||
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
|
||||
if (!isCurrentOperation()) return;
|
||||
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
|
||||
if (!response?.ok) {
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
return;
|
||||
}
|
||||
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
if (!isCurrentOperation()) return;
|
||||
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
||||
logConnect('password:token', { issued: Boolean(issuedToken) });
|
||||
|
||||
@@ -1515,8 +1594,11 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
|
||||
// Persist the token BEFORE switching (no fire-and-forget).
|
||||
if (isCapacitorApp()) {
|
||||
if (!isCurrentOperation()) return;
|
||||
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
|
||||
if (!isCurrentOperation()) return;
|
||||
}
|
||||
if (!isCurrentOperation()) return;
|
||||
persistMetadata({ id, label, candidates, clientToken: issuedToken });
|
||||
setPendingConnection(null);
|
||||
// A relay transport hands its live login tunnel to the runtime (adopted
|
||||
@@ -1527,20 +1609,24 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
{ runtimeKey: secureTokenKeyOf({ candidates }) },
|
||||
);
|
||||
adopted = chosen.kind === 'relay';
|
||||
if (!isCurrentOperation()) return;
|
||||
onConnected();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation()) return;
|
||||
console.warn('[mobile-connect] password threw', error);
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
} finally {
|
||||
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
|
||||
endBusy('password');
|
||||
if (isCurrentOperation()) endBusy('password');
|
||||
}
|
||||
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
|
||||
|
||||
const cancelPassword = React.useCallback(() => {
|
||||
passwordOperationRef.current.cancel();
|
||||
endBusy('password');
|
||||
setPendingConnection(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
}, [endBusy]);
|
||||
|
||||
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
|
||||
setError(null);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import React from 'react';
|
||||
|
||||
import { observeNativeKeyboardHeight, resetHardwareKeyboardDetection, startHardwareKeyboardBridge } from '@/lib/hardwareKeyboard';
|
||||
|
||||
/** True when running inside the native Capacitor shell (iOS/Android app). */
|
||||
export const isCapacitorMobileApp = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const maybeCapacitor = (window as typeof window & {
|
||||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||||
}).Capacitor;
|
||||
if (maybeCapacitor?.isNativePlatform?.() === true) return true;
|
||||
return window.location.protocol === 'capacitor:';
|
||||
};
|
||||
|
||||
export const useNativeMobileChrome = (): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
const root = document.documentElement;
|
||||
// Marks the Capacitor shell so keyboard-inset CSS only applies here, not in
|
||||
// the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget).
|
||||
root.classList.add('oc-capacitor-app');
|
||||
// Platform marker: Android resizes the window for the keyboard natively (no manual
|
||||
// inset/choreography — the keyboard listeners below skip Android entirely).
|
||||
const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (capacitorPlatform === 'android') {
|
||||
root.classList.add('oc-platform-android');
|
||||
}
|
||||
|
||||
// iOS reports hardware keyboards natively (GCKeyboard); adopting that
|
||||
// answer switches the layout off its keyboard-event inference entirely.
|
||||
cleanup.push(startHardwareKeyboardBridge());
|
||||
|
||||
const setInset = (px: number) => {
|
||||
root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
|
||||
void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => {
|
||||
if (disposed) return;
|
||||
// Keep the status bar transparent over the WebView. A custom UIScene lifecycle
|
||||
// (iOS 26) plus returning from background can silently drop the overlay state,
|
||||
// letting an opaque status-bar background flash in at the top — so re-assert it
|
||||
// on mount, once shortly after (startup race), and whenever the app re-activates.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
const applyStatusBar = async () => {
|
||||
if (platform === 'android') {
|
||||
// Inset the WebView below the bar and paint it with the resolved theme background
|
||||
// (the splash colours the theme system persists). On Android 15+ edge-to-edge is
|
||||
// enforced and both calls are no-ops — there the app pads itself via the
|
||||
// Capacitor-injected --safe-area-inset-* CSS vars (see mobile.css, oc-platform-android).
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themeBg =
|
||||
(isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) ||
|
||||
(isDark ? '#171515' : '#fffdf4');
|
||||
await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined);
|
||||
await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined);
|
||||
// Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg),
|
||||
// Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light.
|
||||
await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined);
|
||||
await StatusBar.show().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined);
|
||||
await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined);
|
||||
await StatusBar.show().catch(() => undefined);
|
||||
};
|
||||
await applyStatusBar();
|
||||
const retry = window.setTimeout(() => void applyStatusBar(), 400);
|
||||
cleanup.push(() => window.clearTimeout(retry));
|
||||
|
||||
const { App } = await import('@capacitor/app');
|
||||
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void applyStatusBar();
|
||||
});
|
||||
if (disposed) {
|
||||
void stateHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void stateHandle.remove());
|
||||
}).catch(() => undefined);
|
||||
|
||||
void import('@capacitor/keyboard').then(async ({ Keyboard }) => {
|
||||
if (disposed) return;
|
||||
// iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard
|
||||
// overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the
|
||||
// window for the keyboard (dvh already shrinks), so applying the inset on top would
|
||||
// double-count — Android gets only the class/event signals below.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (platform === 'android') {
|
||||
// Android resizes the WebView natively, so no inset/transform
|
||||
// choreography — but the UI still needs the open/closed signal:
|
||||
// oc-keyboard-open drives CSS (draft starters, composer padding), and
|
||||
// the settled event gives the chat its one deterministic re-pin after
|
||||
// the native resize (the auto-follow idle gate ignores it otherwise).
|
||||
const willShowHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
root.classList.add('oc-keyboard-open');
|
||||
// The composer already expanded on tap — re-pin the chat to it now,
|
||||
// so the native resize that follows is the only remaining movement.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => {
|
||||
// Same single-motion trick as iOS: collapse the composer into the
|
||||
// pill synchronously (flushSync in ChatInput) so the native window
|
||||
// growth and the composer shrink land together, not as two steps.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } }));
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
});
|
||||
const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } }));
|
||||
});
|
||||
const removeAll = () => {
|
||||
void willShowHandle.remove();
|
||||
void didShowHandle.remove();
|
||||
void willHideHandle.remove();
|
||||
void didHideHandle.remove();
|
||||
};
|
||||
if (disposed) {
|
||||
removeAll();
|
||||
return;
|
||||
}
|
||||
cleanup.push(removeAll);
|
||||
return;
|
||||
}
|
||||
// No WebKit form accessory bar (prev/next arrows + Done) above the keyboard —
|
||||
// there's a single input, so it only eats vertical space.
|
||||
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
||||
|
||||
// Keyboard slide choreography (see the "Native (Capacitor) keyboard handling"
|
||||
// block in mobile.css for the full picture). `keyboardWillShow` fires at the
|
||||
// START of the iOS keyboard animation and carries the final height; the
|
||||
// visible motion is transform-only (inline styles on the kb-movers), and the shell's layout
|
||||
// height (--oc-kb-layout) snaps exactly once per open/close at the moment the
|
||||
// resize is invisible. visualViewport tracking was tried but doesn't shrink
|
||||
// under WKWebView's `resize: 'none'`, so these events are the reliable signal.
|
||||
const KB_ANIM_MS = 250;
|
||||
// Dismissal reads faster than the rise — run the hide leg shorter (kept in
|
||||
// sync with the .oc-kb-hide transition-duration override in mobile.css).
|
||||
const KB_HIDE_MS = 200;
|
||||
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
||||
let settleTimer: number | null = null;
|
||||
let caretTimer: number | null = null;
|
||||
let keyboardHeight = 0;
|
||||
let layoutApplied = false;
|
||||
let safeBottomPx = 0;
|
||||
let keyboardOpen = false;
|
||||
|
||||
const setVar = (name: string, px: number) => {
|
||||
root.style.setProperty(name, `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
const clearSettle = () => {
|
||||
if (settleTimer !== null) {
|
||||
window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
};
|
||||
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
};
|
||||
// Elements that ride the keyboard slide, with their travel factor. Driven
|
||||
// by INLINE styles from here: WebKit does not reliably start a transition
|
||||
// when the transform's value changes via a CSS custom property, which
|
||||
// left the composer parked until the keyboard finished.
|
||||
const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => {
|
||||
const movers: Array<{ el: HTMLElement; factor: number }> = [];
|
||||
const composer = document.querySelector<HTMLElement>('.oc-mobile-composer');
|
||||
if (composer) movers.push({ el: composer, factor: 1 });
|
||||
// The centered draft title moves half the shift — exactly where the
|
||||
// center lands after the shell snap (see mobile.css notes).
|
||||
const draftCenter = document.querySelector<HTMLElement>('.oc-draft-center');
|
||||
if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 });
|
||||
return movers;
|
||||
};
|
||||
const clearKbMovers = () => {
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
}
|
||||
};
|
||||
|
||||
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
clearSettle();
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
keyboardOpen = true;
|
||||
keyboardHeight = info.keyboardHeight;
|
||||
if (!layoutApplied) {
|
||||
// The shell's resolved padding-bottom while the keyboard is down IS the
|
||||
// bottom safe padding it gives up when open — measure it so the slide
|
||||
// distance lands the composer exactly where the final layout puts it.
|
||||
const shell = document.querySelector('.oc-mobile-app-shell');
|
||||
safeBottomPx = shell ? parseFloat(getComputedStyle(shell).paddingBottom) || 0 : 0;
|
||||
}
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-kb-hide');
|
||||
// WKWebView renders the caret as a native layer that doesn't ride CSS
|
||||
// transforms — after the rise it visibly "flies" from the pre-keyboard
|
||||
// position to the final one. Hide it for the transition (plus the lag
|
||||
// window where UIKit animates it into place) and pop it back in.
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold');
|
||||
setInset(keyboardHeight);
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Reserve the keyboard strip inside the chat scroller NOW and re-pin
|
||||
// immediately (settled = one cheap scrollTop write over already-mounted
|
||||
// rows), so the chat bottom moves as the keyboard STARTS rising instead
|
||||
// of waiting for it to finish. `slide` (keyboard minus the safe inset
|
||||
// the shell gives up) is exactly the strip the scroller loses at
|
||||
// settle, so pin position and settle stay geometry-neutral.
|
||||
setVar('--oc-kb-scroll-inset', slide);
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
// Invisible swap: transition off, layout takes the keyboard height (one
|
||||
// reflow), shift returns to 0 in the same frame.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', keyboardHeight);
|
||||
layoutApplied = true;
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
// Reveal the caret only after UIKit's own caret reposition window.
|
||||
caretTimer = window.setTimeout(() => {
|
||||
caretTimer = null;
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
}, 250);
|
||||
}, KB_ANIM_MS + 20);
|
||||
});
|
||||
|
||||
// Shared hide choreography. The bridge's `keyboardWillHide` can arrive a
|
||||
// beat AFTER the native dismiss animation has already started (WKWebView +
|
||||
// resize: 'none'), which made the composer begin its down-slide only once
|
||||
// the keyboard was gone. The earliest reliable signal for the common
|
||||
// dismissal path (tap outside the input) is the textarea's focusout — so
|
||||
// both trigger this, and `keyboardOpen` makes the second call a no-op.
|
||||
const runHide = () => {
|
||||
if (!keyboardOpen) return;
|
||||
keyboardOpen = false;
|
||||
clearSettle();
|
||||
// Fired BEFORE any layout change: lets the composer collapse into its
|
||||
// pill synchronously (flushSync in ChatInput), so the keyboard hide
|
||||
// compensation below measures keyboard + composer shrink as ONE delta
|
||||
// instead of two staggered steps.
|
||||
dispatchKb('oc:keyboard-intent', { open: false });
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
setInset(0);
|
||||
setVar('--oc-kb-scroll-inset', 0);
|
||||
if (layoutApplied) {
|
||||
// Settled-open → restore the full-height layout NOW (still hidden behind
|
||||
// the keyboard) and FLIP the movers to their raised position without
|
||||
// transitioning, so the next frame looks unchanged.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', 0);
|
||||
layoutApplied = false;
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Force the style/layout flush so the transition below starts from the
|
||||
// FLIP position instead of coalescing both writes into one frame.
|
||||
void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight;
|
||||
}
|
||||
// If the hide interrupted a show mid-animation (layout not applied yet),
|
||||
// the movers transition back down from wherever they currently are.
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING });
|
||||
root.classList.add('oc-kb-animating', 'oc-kb-hide');
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = 'translateY(0px)';
|
||||
}
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: false });
|
||||
}, KB_HIDE_MS + 20);
|
||||
};
|
||||
|
||||
const hideHandle = await Keyboard.addListener('keyboardWillHide', runHide);
|
||||
|
||||
// Early hide trigger: blurring the focused text field is what starts the
|
||||
// native dismiss animation, and it happens in-page — no bridge latency.
|
||||
// Deferred a task so a synchronous refocus (focus moving to another text
|
||||
// input, or a control that restores focus) doesn't false-trigger; in that
|
||||
// case the keyboard never hides and `keyboardWillHide` never fires either.
|
||||
const isTextInput = (node: unknown): boolean =>
|
||||
node instanceof HTMLElement
|
||||
&& (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable);
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (!keyboardOpen) return;
|
||||
if (!isTextInput(event.target)) return;
|
||||
if (isTextInput(event.relatedTarget)) return;
|
||||
window.setTimeout(() => {
|
||||
if (!keyboardOpen) return;
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
runHide();
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener('focusout', handleFocusOut, true);
|
||||
|
||||
if (disposed) {
|
||||
clearSettle();
|
||||
document.removeEventListener('focusout', handleFocusOut, true);
|
||||
void showHandle.remove();
|
||||
void hideHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(
|
||||
clearSettle,
|
||||
() => {
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
},
|
||||
() => document.removeEventListener('focusout', handleFocusOut, true),
|
||||
() => void showHandle.remove(),
|
||||
() => void hideHandle.remove(),
|
||||
);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
resetHardwareKeyboardDetection();
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android');
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
root.style.removeProperty('--oc-kb-layout');
|
||||
root.style.removeProperty('--oc-kb-scroll-inset');
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useNativeMobileLifecycle = (onResume: () => void): void => {
|
||||
const wasInactiveRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
const resumeAfterInactive = () => {
|
||||
if (!wasInactiveRef.current) return;
|
||||
wasInactiveRef.current = false;
|
||||
onResume();
|
||||
};
|
||||
|
||||
// Belt-and-suspenders resume detection. Capacitor's `appStateChange` is the
|
||||
// primary signal, but on iOS it can be missed after a long suspend, so the
|
||||
// webview's own `visibilitychange` is a second trigger — either one flips
|
||||
// wasInactiveRef and fires onResume exactly once per background→foreground.
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
wasInactiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
resumeAfterInactive();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
cleanup.push(() => document.removeEventListener('visibilitychange', handleVisibility));
|
||||
|
||||
void import('@capacitor/app').then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const state = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
document.documentElement.classList.toggle('oc-native-app-active', isActive);
|
||||
if (!isActive) {
|
||||
wasInactiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
resumeAfterInactive();
|
||||
});
|
||||
const resume = await App.addListener('resume', resumeAfterInactive);
|
||||
if (disposed) {
|
||||
void state.remove();
|
||||
void resume.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void state.remove(), () => void resume.remove());
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
};
|
||||
}, [onResume]);
|
||||
};
|
||||
|
||||
export const useNativeAndroidBackButton = (onBack: () => boolean): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
let remove: (() => void) | null = null;
|
||||
|
||||
void import('@capacitor/app').then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const listener = await App.addListener('backButton', () => {
|
||||
if (onBack()) return;
|
||||
void App.minimizeApp().catch(() => undefined);
|
||||
});
|
||||
if (disposed) {
|
||||
void listener.remove();
|
||||
return;
|
||||
}
|
||||
remove = () => void listener.remove();
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
remove?.();
|
||||
};
|
||||
}, [onBack]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
export const normalizePath = (value?: string | null): string =>
|
||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
export const getProjectLabel = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
|
||||
};
|
||||
|
||||
export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
|
||||
if (project) return project.label?.trim() || getProjectLabel(project.path);
|
||||
return getProjectLabel(fallbackDirectory);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
@@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => {
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanConnectionQr on Android', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
});
|
||||
|
||||
test('uses the bundled startScan flow and cleans up after a result', async () => {
|
||||
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
|
||||
let removeCalls = 0;
|
||||
let stopCalls = 0;
|
||||
let scanCalls = 0;
|
||||
let startOptions: unknown;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const scan = async () => { scanCalls += 1; return { barcodes: [] }; };
|
||||
const startScan = async (options?: unknown) => {
|
||||
startOptions = options;
|
||||
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] });
|
||||
};
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
scan,
|
||||
startScan,
|
||||
stopScan,
|
||||
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
|
||||
listeners.set(event, callback);
|
||||
return Promise.resolve({ remove });
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' });
|
||||
expect(startOptions).toEqual({ formats: ['QR_CODE'] });
|
||||
expect(scanCalls).toBe(0);
|
||||
expect(stopCalls).toBe(1);
|
||||
expect(removeCalls).toBe(2);
|
||||
});
|
||||
|
||||
test('stops scanning when the caller aborts', async () => {
|
||||
let stopCalls = 0;
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: mock(async () => undefined),
|
||||
stopScan,
|
||||
addListener: mock(async () => ({ remove: mock(() => undefined) })),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('waits for listener setup to finish before cleaning up an aborted scan', async () => {
|
||||
let finishListenerSetup: (() => void) | undefined;
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const listenerSetup = new Promise<void>((resolve) => { finishListenerSetup = resolve; });
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const startScan = async () => { startCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan,
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async () => {
|
||||
await listenerSetup;
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
finishListenerSetup?.();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(2);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('cleans up successful listener registration when the other listener fails', async () => {
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: async () => { startCalls += 1; },
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async (event: string) => {
|
||||
if (event === 'scanError') throw new Error('listener setup failed');
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'failed' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(1);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||
//
|
||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
||||
// bare http(s) URL so a QR encoding only the server address works.
|
||||
//
|
||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made
|
||||
// scan() activity, this path bundles the barcode model in the app and does not need
|
||||
// Google Play Services. iOS keeps the native ready-made scanner.
|
||||
|
||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
@@ -32,74 +26,16 @@ export type QrScanResult =
|
||||
| { status: 'failed' };
|
||||
|
||||
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
||||
|
||||
type ModuleInstallProgress = { state?: number };
|
||||
type ListenerHandle = { remove: () => void };
|
||||
|
||||
type ListenerHandle = { remove: () => void | Promise<void> };
|
||||
type BarcodeScannerPlugin = {
|
||||
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
||||
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
||||
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
|
||||
// which Play Services must download once before the first scan. Absent on iOS.
|
||||
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
|
||||
installGoogleBarcodeScannerModule?: () => Promise<void>;
|
||||
startScan?: (options?: { formats?: string[] }) => Promise<void>;
|
||||
stopScan?: () => Promise<void>;
|
||||
addListener?: (
|
||||
event: 'googleBarcodeScannerModuleInstallProgress',
|
||||
cb: (info: ModuleInstallProgress) => void,
|
||||
) => Promise<ListenerHandle>;
|
||||
};
|
||||
|
||||
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
|
||||
const MODULE_STATE_COMPLETED = 4;
|
||||
const MODULE_STATE_CANCELED = 3;
|
||||
const MODULE_STATE_FAILED = 5;
|
||||
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
|
||||
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
|
||||
// is usable; rejects if the install is canceled, fails, or times out.
|
||||
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (
|
||||
capacitor?.getPlatform?.() !== 'android' ||
|
||||
!plugin.isGoogleBarcodeScannerModuleAvailable ||
|
||||
!plugin.installGoogleBarcodeScannerModule
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
|
||||
if (status?.available) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let handle: ListenerHandle | undefined;
|
||||
const finish = (fn: () => void) => {
|
||||
window.clearTimeout(timer);
|
||||
handle?.remove();
|
||||
fn();
|
||||
};
|
||||
const timer = window.setTimeout(
|
||||
() => finish(() => reject(new Error('module install timed out'))),
|
||||
MODULE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
// addListener may return a handle synchronously OR a Promise<handle> depending on the
|
||||
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
|
||||
// and abort the install call below.
|
||||
Promise.resolve(
|
||||
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
|
||||
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
|
||||
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
|
||||
finish(() => reject(new Error('module install failed')));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
handle = h as ListenerHandle | undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
|
||||
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
|
||||
);
|
||||
});
|
||||
event: 'barcodesScanned' | 'scanError',
|
||||
cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void,
|
||||
) => Promise<ListenerHandle> | ListenerHandle;
|
||||
};
|
||||
|
||||
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
@@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||
}).Capacitor;
|
||||
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
||||
return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null;
|
||||
};
|
||||
|
||||
const isAndroid = (): boolean => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
return capacitor?.getPlatform?.() === 'android';
|
||||
};
|
||||
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||
@@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
||||
return null;
|
||||
};
|
||||
|
||||
// The Google code scanner can briefly still throw "module not available" in the moments right
|
||||
// after its install completes. Detect that specific error so we can re-ensure + retry rather
|
||||
// than surfacing a failure the user would have to manually tap through.
|
||||
const isModuleUnavailableError = (error: unknown): boolean => {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? '')
|
||||
: String(error ?? '');
|
||||
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
|
||||
const resultFromRawValue = (raw: string): QrScanResult => {
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
};
|
||||
|
||||
const scanWithBundledAndroidScanner = async (
|
||||
plugin: BarcodeScannerPlugin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QrScanResult> => {
|
||||
if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' };
|
||||
if (signal?.aborted) return { status: 'cancelled' };
|
||||
|
||||
let barcodeListener: ListenerHandle | undefined;
|
||||
let errorListener: ListenerHandle | undefined;
|
||||
let settled = false;
|
||||
let resolveResult: (result: QrScanResult) => void = () => undefined;
|
||||
|
||||
const result = new Promise<QrScanResult>((resolve) => {
|
||||
resolveResult = resolve;
|
||||
});
|
||||
const finish = (scanResult: QrScanResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveResult(scanResult);
|
||||
};
|
||||
const abort = () => finish({ status: 'cancelled' });
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
|
||||
try {
|
||||
const listenerResults = await Promise.allSettled([
|
||||
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||
const barcode = barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (raw) finish(resultFromRawValue(raw));
|
||||
})).then((handle) => { barcodeListener = handle; }),
|
||||
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||
.then((handle) => { errorListener = handle; }),
|
||||
]);
|
||||
|
||||
if (listenerResults.some(({ status }) => status === 'rejected')) {
|
||||
finish({ status: 'failed' });
|
||||
} else if (!settled) {
|
||||
void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' }));
|
||||
}
|
||||
|
||||
return await result;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
await Promise.allSettled([
|
||||
Promise.resolve(barcodeListener?.remove()),
|
||||
Promise.resolve(errorListener?.remove()),
|
||||
plugin.stopScan(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
||||
|
||||
export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
||||
export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise<QrScanResult> => {
|
||||
const plugin = getScannerPlugin();
|
||||
if (!plugin?.scan) return { status: 'unsupported' };
|
||||
if (!plugin) return { status: 'unsupported' };
|
||||
|
||||
try {
|
||||
if (plugin.requestPermissions) {
|
||||
const permission = await plugin.requestPermissions();
|
||||
const camera = permission?.camera;
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') {
|
||||
return { status: 'permission-denied' };
|
||||
}
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' };
|
||||
}
|
||||
|
||||
// First scan on Android downloads the Google barcode module (the button stays in its
|
||||
// scanning state for the whole wait). The module can still report "not available" for a
|
||||
// moment right after install, so re-ensure + retry within this same call instead of erroring
|
||||
// out — the user shouldn't have to guess to tap again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await ensureScannerModule(plugin);
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (!raw) return { status: 'cancelled' };
|
||||
if (options?.signal?.aborted) return { status: 'cancelled' };
|
||||
if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal);
|
||||
if (!plugin.scan) return { status: 'unsupported' };
|
||||
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
} catch (error) {
|
||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
return { status: 'failed' };
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
return raw ? resultFromRawValue(raw) : { status: 'cancelled' };
|
||||
} catch {
|
||||
return { status: 'failed' };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* Builds the lightweight session overview the native iOS widgets render (home medium,
|
||||
@@ -26,9 +29,11 @@ export interface MobileWidgetSession {
|
||||
}
|
||||
|
||||
export interface MobileWidgetSnapshot {
|
||||
/** Runtime instance that owns all session IDs and paths in this snapshot. */
|
||||
runtimeKey: string;
|
||||
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
||||
attentionCount: number;
|
||||
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
|
||||
/** Top-level sessions in the app's shared lifecycle order (capped for the medium widget). */
|
||||
recentSessions: MobileWidgetSession[];
|
||||
}
|
||||
|
||||
@@ -70,9 +75,11 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
|
||||
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const pinnedSessionIds = useSessionPinnedStore.getState().ids;
|
||||
const sessionOrderRanks = useSessionOrderingStore.getState().rankById;
|
||||
|
||||
let attentionCount = 0;
|
||||
const topLevel: Array<{ id: string; title: string; updated: number; unread: boolean; project: string }> = [];
|
||||
const topLevel: Array<{ session: Session; unread: boolean; project: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const isSubtask = parentIdOf(session) !== null;
|
||||
@@ -83,21 +90,19 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
}
|
||||
if (!isSubtask) {
|
||||
topLevel.push({
|
||||
id: session.id,
|
||||
title: session.title ?? '',
|
||||
updated: session.time?.updated ?? session.time?.created ?? 0,
|
||||
session,
|
||||
unread: needsAttention,
|
||||
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.sort((a, b) => b.updated - a.updated);
|
||||
topLevel.sort((a, b) => compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, sessionOrderRanks));
|
||||
const recentSessions = topLevel
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
|
||||
.map(({ session, unread, project }) => ({ id: session.id, title: session.title ?? '', unread, project }));
|
||||
|
||||
return { attentionCount, recentSessions };
|
||||
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
|
||||
};
|
||||
|
||||
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
|
||||
|
||||
@@ -44,6 +44,10 @@ const initializeSharedPreferences = () => {
|
||||
};
|
||||
|
||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||
// Stamp the surface before anything else reads it: perf tuning, sync paging,
|
||||
// and device info all key off isMobileSurfaceRuntime(), and without the stamp
|
||||
// a wide native device (iPad landscape) would fall out of the mobile branch.
|
||||
window.__OPENCHAMBER_SURFACE__ = 'mobile';
|
||||
preloadMarkdownRenderer();
|
||||
initializeSharedPreferences();
|
||||
|
||||
|
||||
@@ -7,8 +7,17 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
// to the new transport WITHOUT tearing down connection/session state or remounting
|
||||
@@ -31,6 +40,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
|
||||
}
|
||||
disposeTerminalInputTransport();
|
||||
useTerminalStore.getState().clearAll();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
useConfigStore.setState({
|
||||
providers: [],
|
||||
@@ -44,8 +54,16 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||
resetSessionOrdering();
|
||||
usePermissionStore.getState().reset();
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
queueMicrotask(() => void syncDesktopSettings());
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Native-feeling edge swipes on the mobile chat: 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
|
||||
*
|
||||
* Only `touchstart`/`touchend` are observed (both passive), so this never
|
||||
* interferes with vertical chat scrolling or the horizontal scroll inside code
|
||||
* blocks — it just reads where the gesture began and ended. The edge zone
|
||||
* keeps it clear of in-content horizontal scroll, which lives away from the
|
||||
* screen edges.
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
// Android reserves the physical screen edge for system navigation. Accept a
|
||||
// wider start area so both OpenChamber drawers can be invoked beyond the
|
||||
// system Back gesture region without changing the browser/iOS gesture.
|
||||
const ANDROID_EDGE_ZONE = 80;
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
export interface EdgeSwipeOptions {
|
||||
/** Swipe that started at the left edge and travelled right. */
|
||||
onLeftEdgeSwipe?: () => void;
|
||||
/** Swipe that started at the right edge and travelled left. */
|
||||
onRightEdgeSwipe?: () => void;
|
||||
}
|
||||
|
||||
export const useEdgeSwipe = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options: EdgeSwipeOptions,
|
||||
): void => {
|
||||
// Keep callbacks in a ref so changing identities don't re-attach the listeners.
|
||||
const optionsRef = React.useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
const edgeZone = platform === 'android' ? ANDROID_EDGE_ZONE : EDGE_ZONE;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= edgeZone;
|
||||
const nearRight = touch.clientX >= width - edgeZone;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dx) < MIN_DISTANCE) return;
|
||||
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
|
||||
// Must travel toward the centre: left edge → rightward, right edge → leftward.
|
||||
if (fromLeftEdge && dx <= 0) return;
|
||||
if (!fromLeftEdge && dx >= 0) return;
|
||||
|
||||
if (fromLeftEdge) optionsRef.current.onLeftEdgeSwipe?.();
|
||||
else optionsRef.current.onRightEdgeSwipe?.();
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/**
|
||||
* Native-feeling edge swipe to switch sessions in the mobile chat: start a horizontal swipe
|
||||
* from the very left/right edge and drag toward the centre to step through sessions.
|
||||
*
|
||||
* - Left edge → centre = previous session (the more-recent one in the list)
|
||||
* - Right edge → centre = next session (the older one)
|
||||
*
|
||||
* Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions
|
||||
* (no subtasks) across all projects, newest-first by `time.updated`. The order is computed at
|
||||
* gesture time from the store (not subscribed) so it's always fresh and never re-attaches.
|
||||
*
|
||||
* Only `touchstart`/`touchend` are observed (both passive), so this never interferes with
|
||||
* vertical chat scrolling or the horizontal scroll inside code blocks — it just reads where the
|
||||
* gesture began and ended. The edge zone keeps it clear of in-content horizontal scroll, which
|
||||
* lives away from the screen edges.
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit a switch
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
const parentIdOf = (session: Session): string | null =>
|
||||
(session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
|
||||
const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0;
|
||||
|
||||
/** Top-level sessions across all projects, newest-first — the list the swipe walks. */
|
||||
const orderedTopLevelSessions = (): Session[] =>
|
||||
useGlobalSessionsStore
|
||||
.getState()
|
||||
.activeSessions.filter((session) => parentIdOf(session) === null)
|
||||
.slice()
|
||||
.sort((a, b) => updatedAt(b) - updatedAt(a));
|
||||
|
||||
/**
|
||||
* Switch to the session `step` positions away from the current one (clamped — no wrap).
|
||||
* Returns true if a switch actually happened.
|
||||
*/
|
||||
const switchByStep = (step: number): boolean => {
|
||||
const ordered = orderedTopLevelSessions();
|
||||
if (ordered.length < 2) return false;
|
||||
|
||||
const currentId = useSessionUIStore.getState().currentSessionId;
|
||||
const index = ordered.findIndex((session) => session.id === currentId);
|
||||
if (index < 0) return false;
|
||||
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= ordered.length) return false;
|
||||
|
||||
const target = ordered[targetIndex];
|
||||
useSessionUIStore.getState().setCurrentSession(target.id, resolveGlobalSessionDirectory(target));
|
||||
return true;
|
||||
};
|
||||
|
||||
export interface EdgeSwipeSessionSwitchOptions {
|
||||
/** Called after a successful switch, with the travel direction, so the caller can animate. */
|
||||
onSwitch?: (direction: 'prev' | 'next') => void;
|
||||
}
|
||||
|
||||
export const useEdgeSwipeSessionSwitch = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options?: EdgeSwipeSessionSwitchOptions,
|
||||
): void => {
|
||||
// Keep onSwitch in a ref so a changing callback identity doesn't re-attach the listeners.
|
||||
const onSwitchRef = React.useRef(options?.onSwitch);
|
||||
onSwitchRef.current = options?.onSwitch;
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= EDGE_ZONE;
|
||||
const nearRight = touch.clientX >= width - EDGE_ZONE;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dx) < MIN_DISTANCE) return;
|
||||
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
|
||||
// Must travel toward the centre: left edge → rightward, right edge → leftward.
|
||||
if (fromLeftEdge && dx <= 0) return;
|
||||
if (!fromLeftEdge && dx >= 0) return;
|
||||
|
||||
const step = fromLeftEdge ? -1 : 1;
|
||||
if (switchByStep(step)) {
|
||||
onSwitchRef.current?.(step < 0 ? 'prev' : 'next');
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -21,6 +21,19 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
// the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register()
|
||||
// returns the right token per platform. The token is sent to the server tagged with its platform
|
||||
// so the relay routes it to APNs vs FCM.
|
||||
// APNs environment of this build. Xcode/dev-signed installs get sandbox device tokens,
|
||||
// TestFlight/App Store installs get production ones; the native iOS shell reports which via
|
||||
// a global injected in SceneDelegate (see packages/mobile/ios/App/App/AppDelegate.swift).
|
||||
// Undefined when the global is absent (Android, or a shell predating the injection) — the
|
||||
// server then defaults to production, matching released builds.
|
||||
const getApnsEnvironment = (): 'sandbox' | 'production' | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const env = (window as typeof window & { __OPENCHAMBER_APNS_ENV__?: string }).__OPENCHAMBER_APNS_ENV__;
|
||||
if (env === 'development') return 'sandbox';
|
||||
if (env === 'production') return 'production';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isNativePushPlatform = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
@@ -56,7 +69,11 @@ export const useNativePushRegistration = (options: { enabled: boolean }): void =
|
||||
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
|
||||
lastTokenRef.current = token.value;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() });
|
||||
void apis?.push?.registerApnsToken?.({
|
||||
token: token.value,
|
||||
platform: getClientPlatform(),
|
||||
environment: getApnsEnvironment(),
|
||||
});
|
||||
});
|
||||
|
||||
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.136 33.9485L12.0218 27.1098L24.136 20.2711L36.2501 27.1098L24.136 33.9485Z" fill="white"/>
|
||||
<path d="M23.907 19.3922C24.1323 19.3336 24.3742 19.3628 24.5822 19.4801L36.6971 26.3176C36.9826 26.4788 37.1585 26.7827 37.1585 27.1106C37.1582 27.4382 36.9824 27.7406 36.6971 27.9017L24.5822 34.7391C24.3048 34.8956 23.9666 34.8957 23.6893 34.7391L11.5743 27.9017C11.2892 27.7405 11.1132 27.4381 11.1129 27.1106C11.1129 26.7828 11.2889 26.4789 11.5743 26.3176L23.6893 19.4801L23.907 19.3922ZM13.8715 27.1086L24.1347 32.9034L34.3999 27.1086L24.1347 21.3139L13.8715 27.1086Z" fill="black"/>
|
||||
<path d="M23.2278 34.4786L12.9307 28.6659V43.5893L23.2278 49.4021V34.4786ZM25.0456 50.9581C25.0456 51.2813 24.8733 51.5822 24.5941 51.7451C24.3152 51.9077 23.9705 51.9097 23.6893 51.7511L11.5743 44.9117C11.2892 44.7504 11.113 44.4483 11.1129 44.1206V27.1098C11.1129 26.7866 11.2852 26.4877 11.5644 26.3248C11.8434 26.1622 12.188 26.16 12.4692 26.3188L24.5822 33.1563C24.8677 33.3175 25.0456 33.6214 25.0456 33.9493V50.9581Z" fill="black"/>
|
||||
<path d="M36.2501 27.1098V44.1205L24.1359 50.9591V42.7527V33.9485L36.2501 27.1098Z" fill="white"/>
|
||||
<path d="M35.8034 26.3188C36.0848 26.1599 36.4291 26.1619 36.7082 26.3248C36.9874 26.4878 37.1597 26.7866 37.1597 27.1098V44.1206C37.1596 44.4485 36.9818 44.7505 36.6962 44.9117L24.5833 51.7512C24.3022 51.9098 23.9574 51.9075 23.6784 51.7452C23.3993 51.5822 23.227 51.2814 23.227 50.9581V33.9493C23.227 33.6214 23.403 33.3175 23.6884 33.1563L35.8034 26.3188ZM25.0447 49.4001L35.3419 43.5893V28.6639L25.0447 34.4786V49.4001Z" fill="black"/>
|
||||
<path d="M48.3642 33.9485L36.25 27.1098L48.3642 20.2711L60.4784 27.1098L48.3642 33.9485Z" fill="white"/>
|
||||
<path d="M48.1352 19.3922C48.3605 19.3336 48.6024 19.3628 48.8104 19.4801L60.9253 26.3176C61.2109 26.4788 61.3867 26.7827 61.3867 27.1106C61.3864 27.4382 61.2106 27.7406 60.9253 27.9017L48.8104 34.7391C48.5331 34.8956 48.1948 34.8957 47.9175 34.7391L35.8026 27.9017C35.5174 27.7405 35.3415 27.4381 35.3412 27.1106C35.3412 26.7828 35.5171 26.4789 35.8026 26.3176L47.9175 19.4801L48.1352 19.3922ZM38.0997 27.1086L48.363 32.9034L58.6282 27.1086L48.363 21.3139L38.0997 27.1086Z" fill="black"/>
|
||||
<path d="M60.4784 27.1098V44.1205L48.3642 50.9591V42.7527V33.9485L60.4784 27.1098Z" fill="white"/>
|
||||
<path d="M60.0317 26.3188C60.3131 26.1599 60.6575 26.1619 60.9366 26.3248C61.2157 26.4878 61.388 26.7866 61.388 27.1098V44.1206C61.388 44.4485 61.2101 44.7505 60.9246 44.9117L48.8117 51.7512C48.5306 51.9098 48.1857 51.9075 47.9068 51.7452C47.6277 51.5822 47.4554 51.2814 47.4554 50.9581V33.9493C47.4554 33.6214 47.6313 33.3175 47.9168 33.1563L60.0317 26.3188ZM49.2731 49.4001L59.5703 43.5893V28.6639L49.2731 34.4786V49.4001Z" fill="black"/>
|
||||
<path d="M24.136 47.6218L12.0218 40.7832L24.136 33.9445L36.2501 40.7832L24.136 47.6218Z" fill="white"/>
|
||||
<path d="M23.907 33.0656C24.1323 33.0069 24.3742 33.0362 24.5822 33.1535L36.6971 39.991C36.9826 40.1522 37.1585 40.4561 37.1585 40.784C37.1582 41.1116 36.9824 41.4139 36.6971 41.575L24.5822 48.4125C24.3048 48.569 23.9666 48.5691 23.6893 48.4125L11.5743 41.575C11.2892 41.4139 11.1132 41.1115 11.1129 40.784C11.1129 40.4561 11.2889 40.1522 11.5743 39.991L23.6893 33.1535L23.907 33.0656ZM13.8715 40.782L24.1347 46.5768L34.3999 40.782L24.1347 34.9872L13.8715 40.782Z" fill="black"/>
|
||||
<path d="M12.0218 40.7831V57.7977L24.136 64.6363V56.4299V47.6218L12.0218 40.7831Z" fill="white"/>
|
||||
<path d="M23.2278 48.1519L12.9307 42.3392V57.2666L23.2278 63.0794V48.1519ZM25.0456 64.6354C25.0456 64.9585 24.8731 65.2575 24.5941 65.4205C24.3151 65.5833 23.9707 65.5871 23.6893 65.4284L11.5743 58.589C11.2892 58.4277 11.113 58.1256 11.1129 57.7979V40.7831C11.1129 40.4599 11.2852 40.1611 11.5644 39.9981C11.8434 39.8355 12.188 39.8333 12.4692 39.9921L24.5822 46.8296C24.8677 46.9908 25.0456 47.2947 25.0456 47.6226V64.6354Z" fill="black"/>
|
||||
<path d="M36.2501 40.7831V57.7977L24.1359 64.6363V56.4299V47.6218L36.2501 40.7831Z" fill="white"/>
|
||||
<path d="M35.8034 39.9921C36.0848 39.8332 36.4291 39.8352 36.7082 39.9981C36.9874 40.1611 37.1597 40.4599 37.1597 40.7831V57.7979C37.1596 58.1258 36.9817 58.4278 36.6962 58.589L24.5833 65.4285C24.3019 65.5873 23.9575 65.5833 23.6784 65.4205C23.3993 65.2575 23.227 64.9587 23.227 64.6354V47.6226C23.227 47.2948 23.403 46.9908 23.6884 46.8296L35.8034 39.9921ZM25.0447 63.0774L35.3419 57.2666V42.3372L25.0447 48.152V63.0774Z" fill="black"/>
|
||||
<path d="M48.3642 47.6218L36.25 40.7832L48.3642 33.9445L60.4784 40.7832L48.3642 47.6218Z" fill="white"/>
|
||||
<path d="M48.1352 33.0656C48.3605 33.0069 48.6024 33.0362 48.8104 33.1535L60.9253 39.991C61.2109 40.1522 61.3867 40.4561 61.3867 40.784C61.3864 41.1116 61.2106 41.4139 60.9253 41.575L48.8104 48.4125C48.533 48.569 48.1948 48.5691 47.9175 48.4125L35.8026 41.575C35.5174 41.4139 35.3414 41.1115 35.3411 40.784C35.3411 40.4561 35.5171 40.1522 35.8026 39.991L47.9175 33.1535L48.1352 33.0656ZM38.0997 40.782L48.3629 46.5768L58.6282 40.782L48.3629 34.9872L38.0997 40.782Z" fill="black"/>
|
||||
<path d="M47.456 48.1519L37.1588 42.3392V57.2666L47.456 63.0794V48.1519ZM49.2738 64.6354C49.2738 64.9585 49.1013 65.2575 48.8223 65.4205C48.5433 65.5833 48.1989 65.5871 47.9175 65.4284L35.8025 58.589C35.5174 58.4277 35.3412 58.1256 35.3411 57.7979V40.7831C35.3411 40.4599 35.5134 40.1611 35.7925 39.9981C36.0716 39.8355 36.4162 39.8333 36.6974 39.9921L48.8103 46.8296C49.0959 46.9908 49.2738 47.2947 49.2738 47.6226V64.6354Z" fill="black"/>
|
||||
<path d="M60.4784 40.7831V57.7977L48.3642 64.6363V56.4299V47.6218L60.4784 40.7831Z" fill="white"/>
|
||||
<path d="M60.0317 39.9921C60.3131 39.8332 60.6575 39.8352 60.9366 39.9981C61.2157 40.1611 61.388 40.4599 61.388 40.7831V57.7979C61.3879 58.1258 61.2101 58.4278 60.9246 58.589L48.8117 65.4285C48.5303 65.5873 48.1859 65.5833 47.9068 65.4205C47.6277 65.2575 47.4554 64.9587 47.4554 64.6354V47.6226C47.4554 47.2948 47.6313 46.9908 47.9168 46.8296L60.0317 39.9921ZM49.2731 63.0774L59.5703 57.2666V42.3372L49.2731 48.152V63.0774Z" fill="black"/>
|
||||
<path d="M35.9374 40.0909C44.9748 40.0909 52.3011 32.7647 52.3011 23.7273C52.3011 14.6899 44.9748 7.36365 35.9374 7.36365C26.9001 7.36365 19.5738 14.6899 19.5738 23.7273C19.5738 32.7647 26.9001 40.0909 35.9374 40.0909Z" fill="white"/>
|
||||
<path d="M51.3922 23.7273C51.3922 15.192 44.4727 8.27251 35.9374 8.27251C27.4021 8.27251 20.4827 15.192 20.4827 23.7273C20.4827 32.2626 27.4021 39.182 35.9374 39.182C44.4727 39.182 51.3922 32.2626 51.3922 23.7273ZM53.2099 23.7273C53.2099 33.2667 45.4769 40.9998 35.9374 40.9998C26.398 40.9998 18.6649 33.2667 18.6649 23.7273C18.6649 14.1878 26.398 6.45477 35.9374 6.45477C45.4769 6.45477 53.2099 14.1878 53.2099 23.7273Z" fill="black"/>
|
||||
<path d="M25.3011 23.1818H31.8465V29.7273H25.3011V23.1818Z" fill="black"/>
|
||||
<path d="M27.3465 25.2273H29.8011V29.7273H27.3465V25.2273Z" fill="white"/>
|
||||
<path d="M40.0284 23.1818H46.5738V29.7273H40.0284V23.1818Z" fill="black"/>
|
||||
<path d="M42.0738 25.2273H44.5284V29.7273H42.0738V25.2273Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="white" stroke="black" stroke-width="3" stroke-linejoin="round">
|
||||
<path d="M12 40L24 34L36 40V52L24 59L12 52V40Z"/>
|
||||
<path d="M36 40L48 34L60 40V52L48 59L36 52V40Z"/>
|
||||
</g>
|
||||
<g stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 40L24 46L36 40M24 46V59"/>
|
||||
<path d="M36 40L48 46L60 40M48 46V59"/>
|
||||
</g>
|
||||
<circle cx="36" cy="25" r="16" fill="white" stroke="black" stroke-width="3"/>
|
||||
<rect x="27" y="22" width="7" height="9" rx="1.5" fill="black"/>
|
||||
<rect x="29.5" y="25" width="2" height="6" fill="white"/>
|
||||
<rect x="38" y="22" width="7" height="9" rx="1.5" fill="black"/>
|
||||
<rect x="40.5" y="25" width="2" height="6" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ComponentFn<P extends Record<string, unknown> = Record<string, unknown>> = (props: P) => unknown;
|
||||
|
||||
@@ -16,12 +16,43 @@ const hookRecords = new Map<unknown, HookRecord>();
|
||||
let currentRecord: HookRecord | null = null;
|
||||
let hookIndex = 0;
|
||||
let pendingEffects: Array<() => void> = [];
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
|
||||
const resetHarness = () => {
|
||||
hookRecords.clear();
|
||||
currentRecord = null;
|
||||
hookIndex = 0;
|
||||
pendingEffects = [];
|
||||
runtimeApiBaseUrl = '';
|
||||
runtimeKey = 'local';
|
||||
runtimeEndpointChangedListener = null;
|
||||
desktopInvoke = async () => null;
|
||||
desktopHostsGetCalls = 0;
|
||||
desktopHostsSetCalls = 0;
|
||||
runtimeSwitchCalls = 0;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: false,
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
},
|
||||
setTimeout: (callback: () => void) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
clearTimeout: () => undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => {
|
||||
@@ -149,6 +180,13 @@ const reactJsxRuntime = {
|
||||
|
||||
let desktopShell = false;
|
||||
let runtimeFetchRejects = true;
|
||||
let runtimeApiBaseUrl = '';
|
||||
let runtimeKey = 'local';
|
||||
let runtimeEndpointChangedListener: (() => void) | null = null;
|
||||
let desktopInvoke: () => Promise<unknown> = async () => null;
|
||||
let desktopHostsGetCalls = 0;
|
||||
let desktopHostsSetCalls = 0;
|
||||
let runtimeSwitchCalls = 0;
|
||||
|
||||
mock.module('react/jsx-runtime', () => reactJsxRuntime);
|
||||
mock.module('react/jsx-dev-runtime', () => reactJsxRuntime);
|
||||
@@ -172,7 +210,7 @@ mock.module('@/components/ui/checkbox', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/input', () => ({
|
||||
Input: () => null,
|
||||
Input: (props: JSXProps) => ({ type: 'input', props }),
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
@@ -200,7 +238,7 @@ mock.module('@/lib/i18n', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
invokeDesktop: mock(() => Promise.resolve(null)),
|
||||
invokeDesktop: () => desktopInvoke(),
|
||||
isDesktopShell: mock(() => desktopShell),
|
||||
isVSCodeRuntime: mock(() => false),
|
||||
}));
|
||||
@@ -232,14 +270,26 @@ mock.module('@/lib/runtime-auth', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
subscribeRuntimeEndpointChanged: mock(() => () => {}),
|
||||
switchRuntimeEndpoint: mock(() => undefined),
|
||||
getRuntimeApiBaseUrl: () => runtimeApiBaseUrl,
|
||||
getRuntimeKey: () => runtimeKey,
|
||||
subscribeRuntimeEndpointChanged: (listener: () => void) => {
|
||||
runtimeEndpointChangedListener = listener;
|
||||
return () => {
|
||||
if (runtimeEndpointChangedListener === listener) runtimeEndpointChangedListener = null;
|
||||
};
|
||||
},
|
||||
switchRuntimeEndpoint: () => { runtimeSwitchCalls += 1; },
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktopHosts', () => ({
|
||||
desktopHostsGet: mock(() => Promise.resolve(null)),
|
||||
desktopHostsSet: mock(() => Promise.resolve()),
|
||||
desktopHostsGet: () => {
|
||||
desktopHostsGetCalls += 1;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
desktopHostsSet: () => {
|
||||
desktopHostsSetCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
getDesktopHostApiUrl: mock(() => ''),
|
||||
normalizeHostUrl: mock(() => ''),
|
||||
}));
|
||||
@@ -288,6 +338,21 @@ const collectText = (node: unknown): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const findElement = (node: unknown, type: string): { type: string; props: JSXProps } | null => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const element = node as { type?: unknown; props?: JSXProps };
|
||||
if (element.type === type && element.props) return { type, props: element.props };
|
||||
const children = element.props?.children;
|
||||
if (Array.isArray(children)) {
|
||||
for (const child of children) {
|
||||
const match = findElement(child, type);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return findElement(children, type);
|
||||
};
|
||||
|
||||
describe('SessionAuthGate status-check failure behavior', () => {
|
||||
test('keeps non-desktop status-check rejection on the error screen', async () => {
|
||||
resetHarness();
|
||||
@@ -312,4 +377,37 @@ describe('SessionAuthGate status-check failure behavior', () => {
|
||||
expect(text).toContain('sessionAuth.locked.unlockTitle');
|
||||
expect(text).not.toContain('sessionAuth.error.networkTitle');
|
||||
});
|
||||
|
||||
test('discards a password completion after switching to another host', async () => {
|
||||
resetHarness();
|
||||
desktopShell = true;
|
||||
runtimeFetchRejects = false;
|
||||
runtimeApiBaseUrl = 'https://host-a.example';
|
||||
runtimeKey = 'host:a';
|
||||
let resolveLogin: (value: unknown) => void = () => {
|
||||
throw new Error('Password login did not start');
|
||||
};
|
||||
desktopInvoke = () => new Promise((resolve) => { resolveLogin = resolve; });
|
||||
|
||||
const lockedTree = await renderGate();
|
||||
const input = findElement(lockedTree, 'input');
|
||||
expect(input).not.toBeNull();
|
||||
(input?.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'password-a' } });
|
||||
|
||||
const passwordTree = await renderGate();
|
||||
const form = findElement(passwordTree, 'form');
|
||||
expect(form).not.toBeNull();
|
||||
const pending = (form?.props.onSubmit as (event: { preventDefault: () => void }) => Promise<void>)({ preventDefault: () => undefined });
|
||||
await Promise.resolve();
|
||||
|
||||
runtimeApiBaseUrl = 'https://host-b.example';
|
||||
runtimeKey = 'host:b';
|
||||
runtimeEndpointChangedListener?.();
|
||||
resolveLogin({ token: 'token-a' });
|
||||
await pending;
|
||||
|
||||
expect(desktopHostsGetCalls).toBe(0);
|
||||
expect(desktopHostsSetCalls).toBe(0);
|
||||
expect(runtimeSwitchCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveStatusCheckFailureState } from './sessionAuthGateState';
|
||||
import { resolveStatusCheckFailureState, runtimeIdentityMatches } from './sessionAuthGateState';
|
||||
|
||||
describe('resolveStatusCheckFailureState', () => {
|
||||
test('keeps the desktop-shell password login fallback intact', () => {
|
||||
@@ -10,4 +10,18 @@ describe('resolveStatusCheckFailureState', () => {
|
||||
test('uses the network error screen for non-desktop status-check failures', () => {
|
||||
expect(resolveStatusCheckFailureState({})).toBe('error');
|
||||
});
|
||||
|
||||
test('rejects async auth results after switching hosts', () => {
|
||||
expect(runtimeIdentityMatches(
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
{ apiBaseUrl: 'https://host-b.example', runtimeKey: 'host:b' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts a credential refresh for the same host', () => {
|
||||
expect(runtimeIdentityMatches(
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,9 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState';
|
||||
import { resolveStatusCheckFailureState, runtimeIdentityMatches, type GateState, type RuntimeIdentity } from './sessionAuthGateState';
|
||||
import {
|
||||
authenticateWithPasskey,
|
||||
cancelPasskeyCeremony,
|
||||
@@ -160,20 +160,34 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => {
|
||||
return isDesktopShell() && !isLocalDesktopRuntime();
|
||||
};
|
||||
|
||||
const captureRuntimeIdentity = (): RuntimeIdentity => ({
|
||||
apiBaseUrl: getRuntimeApiBaseUrl(),
|
||||
runtimeKey: getRuntimeKey(),
|
||||
});
|
||||
|
||||
const isRuntimeIdentityActive = (identity: RuntimeIdentity): boolean => {
|
||||
return runtimeIdentityMatches(identity, captureRuntimeIdentity());
|
||||
};
|
||||
|
||||
type DesktopPasswordLoginResult = {
|
||||
token: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<DesktopPasswordLoginResult | null> => {
|
||||
const issueDesktopClientTokenViaShell = async (
|
||||
password: string,
|
||||
trustDevice: boolean,
|
||||
runtime: RuntimeIdentity,
|
||||
requestHeaders: Record<string, string>,
|
||||
): Promise<DesktopPasswordLoginResult | null> => {
|
||||
if (!isDesktopShell() || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const response = await invokeDesktop('desktop_remote_password_login', {
|
||||
url: getRuntimeApiBaseUrl(),
|
||||
url: runtime.apiBaseUrl,
|
||||
password,
|
||||
trustDevice,
|
||||
requestHeaders: getRuntimeExtraHeadersSync(),
|
||||
requestHeaders,
|
||||
}).catch(() => null);
|
||||
if (!response || typeof response !== 'object') {
|
||||
return null;
|
||||
@@ -186,22 +200,22 @@ const issueDesktopClientTokenViaShell = async (password: string, trustDevice: bo
|
||||
};
|
||||
};
|
||||
|
||||
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
|
||||
if (!isDesktopShell() || !clientToken) return;
|
||||
const persistDesktopClientToken = async (runtime: RuntimeIdentity, clientToken: string): Promise<boolean> => {
|
||||
if (!isDesktopShell() || !clientToken || !isRuntimeIdentityActive(runtime)) return false;
|
||||
const cfg = await desktopHostsGet().catch(() => null);
|
||||
if (!cfg) return;
|
||||
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) {
|
||||
if (!cfg || !isRuntimeIdentityActive(runtime)) return false;
|
||||
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, runtime.apiBaseUrl)) {
|
||||
await desktopHostsSet({
|
||||
hosts: cfg.hosts,
|
||||
defaultHostId: cfg.defaultHostId,
|
||||
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
||||
localClientToken: clientToken,
|
||||
}).catch(() => undefined);
|
||||
return;
|
||||
return isRuntimeIdentityActive(runtime);
|
||||
}
|
||||
let changed = false;
|
||||
const hosts = cfg.hosts.map((host) => {
|
||||
if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) {
|
||||
if (!sameOrigin(getDesktopHostApiUrl(host), runtime.apiBaseUrl)) {
|
||||
return host;
|
||||
}
|
||||
if (host.clientToken === clientToken) {
|
||||
@@ -210,24 +224,31 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string
|
||||
changed = true;
|
||||
return { ...host, clientToken };
|
||||
});
|
||||
if (!changed) return;
|
||||
if (!changed) return true;
|
||||
if (!isRuntimeIdentityActive(runtime)) return false;
|
||||
await desktopHostsSet({
|
||||
hosts,
|
||||
defaultHostId: cfg.defaultHostId,
|
||||
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
||||
}).catch(() => undefined);
|
||||
return isRuntimeIdentityActive(runtime);
|
||||
};
|
||||
|
||||
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
|
||||
if (!clientToken) return;
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
await persistDesktopClientToken(apiBaseUrl, clientToken);
|
||||
const applyDesktopClientToken = async (
|
||||
clientToken: string,
|
||||
runtime: RuntimeIdentity,
|
||||
requestHeaders: Record<string, string>,
|
||||
): Promise<boolean> => {
|
||||
if (!clientToken || !isRuntimeIdentityActive(runtime)) return false;
|
||||
if (!await persistDesktopClientToken(runtime, clientToken)) return false;
|
||||
if (!isRuntimeIdentityActive(runtime)) return false;
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl,
|
||||
apiBaseUrl: runtime.apiBaseUrl,
|
||||
clientToken,
|
||||
requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
|
||||
runtimeKey: runtime.runtimeKey,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -338,17 +359,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
|
||||
}, [trustDevice]);
|
||||
|
||||
const refreshPasskeyStatus = React.useCallback(async () => {
|
||||
const refreshPasskeyStatus = React.useCallback(async (runtime = captureRuntimeIdentity()) => {
|
||||
if (skipAuth) {
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextStatus = await fetchPasskeyStatus();
|
||||
setPasskeyStatus(nextStatus);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setPasskeyStatus(nextStatus);
|
||||
}
|
||||
return nextStatus;
|
||||
} catch {
|
||||
setPasskeyStatus(defaultPasskeyStatus);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setPasskeyStatus(defaultPasskeyStatus);
|
||||
}
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
}, [skipAuth]);
|
||||
@@ -423,14 +448,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = captureRuntimeIdentity();
|
||||
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||
try {
|
||||
const [response, latestPasskeyStatus] = await Promise.all([
|
||||
fetchSessionStatus(),
|
||||
refreshPasskeyStatus(),
|
||||
refreshPasskeyStatus(runtime),
|
||||
]);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!isRuntimeIdentityActive(runtime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
resetTransientRetry();
|
||||
setState('authenticated');
|
||||
@@ -472,6 +502,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) {
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to check session status:', error);
|
||||
if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') {
|
||||
setState('locked');
|
||||
@@ -504,10 +537,14 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
|
||||
return subscribeRuntimeEndpointChanged(() => {
|
||||
cancelPasskeyCeremony();
|
||||
setPassword('');
|
||||
setErrorMessage('');
|
||||
setRetryAfter(undefined);
|
||||
setIsTunnelLocked(false);
|
||||
setIsSubmitting(false);
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
resetTransientRetry();
|
||||
setState('pending');
|
||||
void checkStatus();
|
||||
@@ -534,8 +571,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await syncDesktopSettings();
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
})();
|
||||
}
|
||||
@@ -547,15 +584,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
};
|
||||
|
||||
const registerPasskeyForCurrentSession = React.useCallback(async () => {
|
||||
const runtime = captureRuntimeIdentity();
|
||||
setActivePasskeyAction('register');
|
||||
setIsPasskeyBusy(true);
|
||||
try {
|
||||
await registerCurrentDevicePasskey();
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
}
|
||||
await refreshPasskeyStatus();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
await refreshPasskeyStatus(runtime);
|
||||
}, [refreshPasskeyStatus]);
|
||||
|
||||
const cancelActivePasskey = React.useCallback(() => {
|
||||
@@ -576,16 +617,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
cancelActivePasskey();
|
||||
}
|
||||
|
||||
const runtime = captureRuntimeIdentity();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
if (shouldUseDesktopShellPasswordLogin()) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return;
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -604,8 +648,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
|
||||
const response = await submitPassword(password, trustDevice);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
const shouldUseClientToken = shouldIssueDesktopClientToken();
|
||||
let clientToken = '';
|
||||
if (shouldUseClientToken) {
|
||||
@@ -613,18 +659,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
? payload.clientToken.trim()
|
||||
: '';
|
||||
if (!clientToken) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
clientToken = shellLogin?.token || await issueDesktopClientToken();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
}
|
||||
}
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
if (clientToken) {
|
||||
await applyDesktopClientToken(clientToken);
|
||||
if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return;
|
||||
}
|
||||
if (enrollPasskey && supportsPasskeys) {
|
||||
try {
|
||||
await registerPasskeyForCurrentSession();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
toast.success(t('sessionAuth.toast.passkeyAdded'));
|
||||
setState('authenticated');
|
||||
return;
|
||||
@@ -662,14 +711,16 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
console.warn('Failed to submit UI password:', error);
|
||||
const shellLogin = shouldUseDesktopShellPasswordLogin()
|
||||
? await issueDesktopClientTokenViaShell(password, trustDevice)
|
||||
? await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders)
|
||||
: null;
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return;
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -689,7 +740,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]);
|
||||
|
||||
@@ -706,6 +759,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsPasskeyBusy(true);
|
||||
setActivePasskeyAction('auth');
|
||||
setErrorMessage('');
|
||||
const runtime = captureRuntimeIdentity();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
|
||||
try {
|
||||
const payload = await authenticateWithPasskey(trustDevice, {
|
||||
@@ -716,13 +771,15 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
||||
? payload.clientToken.trim()
|
||||
: '';
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (clientToken) {
|
||||
await applyDesktopClientToken(clientToken);
|
||||
if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return;
|
||||
}
|
||||
|
||||
setPassword('');
|
||||
setState('authenticated');
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
@@ -730,8 +787,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
}
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]);
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
|
||||
|
||||
export type RuntimeIdentity = {
|
||||
apiBaseUrl: string;
|
||||
runtimeKey: string;
|
||||
};
|
||||
|
||||
export const runtimeIdentityMatches = (left: RuntimeIdentity, right: RuntimeIdentity): boolean => {
|
||||
return left.apiBaseUrl === right.apiBaseUrl && left.runtimeKey === right.runtimeKey;
|
||||
};
|
||||
|
||||
export const resolveStatusCheckFailureState = (options: {
|
||||
shouldUseDesktopShellPasswordLogin?: boolean;
|
||||
}): Exclude<GateState, 'pending' | 'authenticated' | 'rate-limited'> => {
|
||||
|
||||
@@ -36,16 +36,16 @@ import { useStreamingStore } from '@/sync/streaming';
|
||||
import {
|
||||
useSessionMessageCount,
|
||||
useSessionMessageRecords,
|
||||
useSessionMessageLoadState,
|
||||
useSyncDirectory,
|
||||
useDirectorySync,
|
||||
useSessionRenderable,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
useParentSession,
|
||||
useSession,
|
||||
} from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
@@ -54,6 +54,9 @@ import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/conte
|
||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
@@ -139,6 +142,7 @@ type HydratingToolSkeletonRow = {
|
||||
|
||||
type ChatViewportProps = {
|
||||
currentSessionId: string;
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
@@ -177,6 +181,7 @@ type ChatViewportProps = {
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
@@ -215,6 +220,11 @@ const ChatViewport = React.memo(({
|
||||
// Shell-mode prompts show their extracted command; cache by message id so
|
||||
// the parts array reference is stable while the command is unchanged.
|
||||
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
|
||||
const shellPreviewSessionRef = React.useRef(currentSessionId);
|
||||
if (shellPreviewSessionRef.current !== currentSessionId) {
|
||||
shellPreviewSessionRef.current = currentSessionId;
|
||||
shellPreviewCache.current.clear();
|
||||
}
|
||||
const promptPreviewsByTurnId = React.useMemo(() => {
|
||||
const next = new Map<string, Part[]>();
|
||||
for (let index = 0; index < renderedMessages.length; index += 1) {
|
||||
@@ -339,6 +349,7 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
@@ -391,6 +402,7 @@ const ChatViewport = React.memo(({
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.currentSessionId === next.currentSessionId
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
@@ -487,12 +499,42 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
|
||||
);
|
||||
};
|
||||
|
||||
const DraftWelcome: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
||||
const projectLabel = useProjectsStore(React.useCallback((state) => {
|
||||
const projectId = selectedProjectId ?? state.activeProjectId;
|
||||
const project = (projectId
|
||||
? state.projects.find((candidate) => candidate.id === projectId)
|
||||
: null) ?? state.projects[0] ?? null;
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [selectedProjectId]));
|
||||
|
||||
return (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
projectLabel
|
||||
? t('chat.emptyState.draftTitleWithProject', { project: projectLabel })
|
||||
: t('chat.emptyState.draftTitle'),
|
||||
projectLabel,
|
||||
)}
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(starter) => useInputStore.getState().requestPresetSubmit(starter.submitText, starter.ref.type)}
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChatContainerProps = {
|
||||
active?: boolean;
|
||||
autoOpenDraft?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
|
||||
const { t } = useI18n();
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
@@ -500,21 +542,22 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
|
||||
// Sync actions
|
||||
const sync = useSync();
|
||||
const syncDirectory = useSyncDirectory();
|
||||
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
||||
const currentSessionKey = currentSessionId
|
||||
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
|
||||
: null;
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
||||
[sync],
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
const loadMoreMessages = React.useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
|
||||
[sync],
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
|
||||
// UI store
|
||||
@@ -542,32 +585,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
),
|
||||
);
|
||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
const hasRenderableSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
// Messages from sync system
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
||||
enabled: active,
|
||||
suspendPartUpdates: Boolean(streamingMessageId),
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const sessionPrefetchInfo = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(notify) => currentSessionId
|
||||
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
|
||||
: () => undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(
|
||||
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(() => undefined, []),
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return;
|
||||
return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length);
|
||||
}, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]);
|
||||
|
||||
// Plan detection - watches messages for plan creation and signals store
|
||||
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
||||
@@ -643,20 +678,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// History metadata — use sync's hasMore/isLoading
|
||||
const historyMeta = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
// Sync's meta is authoritative once a fetch has confirmed the history
|
||||
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
|
||||
// the initial page) must not keep the "load older" affordance alive
|
||||
// after the user has already reached the top.
|
||||
const syncComplete = sync.isComplete(currentSessionId);
|
||||
const prefetchHasMore = !syncComplete
|
||||
&& Boolean(sessionPrefetchInfo?.cursor)
|
||||
&& sessionPrefetchInfo?.complete !== true;
|
||||
return {
|
||||
limit: sessionMessages.length,
|
||||
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
||||
loading: sync.isLoading(currentSessionId),
|
||||
complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
|
||||
loading: sessionMessageLoadState.status === 'loading',
|
||||
};
|
||||
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
|
||||
}, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
@@ -668,17 +695,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const isDesktopExpandedInput = isExpandedInput;
|
||||
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
const draftProjectLabel = React.useMemo(() => {
|
||||
const selectedProject = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
const activeProject = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId) ?? null
|
||||
: null;
|
||||
const project = selectedProject ?? activeProject ?? projects[0] ?? null;
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
// In the embedded session-chat iframe, hide "Return to parent" when
|
||||
@@ -712,13 +729,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
{t('chat.container.returnToParent.label')}
|
||||
</Button>
|
||||
) : null;
|
||||
const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly;
|
||||
const promptReadOnly = resolveChatPromptReadOnly(currentSession, allowPromptingSubagentSessions, readOnly);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || window.parent === window) {
|
||||
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
|
||||
// The old `window.parent === window` check does not catch that, so
|
||||
// `window.parent.postMessage(...)` threw on chat open:
|
||||
// TypeError: Cannot read properties of undefined (reading 'postMessage')
|
||||
if (typeof window === 'undefined' || !window.parent || window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentWindow = window.parent;
|
||||
const applySetting = (value: boolean) => {
|
||||
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
||||
};
|
||||
@@ -729,7 +751,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
applySetting(payload.allowPromptingSubagentSessions);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window.parent || event.origin !== window.location.origin) return;
|
||||
if (event.source !== parentWindow || event.origin !== window.location.origin) return;
|
||||
const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } };
|
||||
if (data?.type !== 'openchamber:chat-settings-sync'
|
||||
|| typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return;
|
||||
@@ -738,7 +760,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
scopedWindow.__openchamberApplyChatSettingsSync = applySync;
|
||||
window.addEventListener('message', handleMessage);
|
||||
window.parent.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
||||
parentWindow.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
||||
@@ -749,7 +771,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
||||
openNewSessionDraft();
|
||||
// Programmatic fallback, not user navigation — must not clear the
|
||||
// persisted last-session pointer the cold-launch restore reads.
|
||||
openNewSessionDraft({ automatic: true });
|
||||
}
|
||||
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
|
||||
@@ -771,6 +795,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
@@ -781,6 +806,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
const timelineController = useChatTimelineController({
|
||||
sessionId: currentSessionId,
|
||||
sessionKey: currentSessionKey,
|
||||
messages: viewportMessages,
|
||||
historyMeta,
|
||||
scrollRef,
|
||||
@@ -932,18 +958,22 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
};
|
||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||
|
||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||
const lastScrolledSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (lastScrolledSessionRef.current === currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
@@ -958,14 +988,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
if (effectiveSessionDirectory !== syncDirectory) return;
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]);
|
||||
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
@@ -987,23 +1016,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// No transform on this root: it would become the containing block for
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel })
|
||||
: t('chat.emptyState.draftTitle'),
|
||||
draftProjectLabel,
|
||||
)}
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex min-h-0',
|
||||
@@ -1025,8 +1039,30 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{returnToParentButton}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 bg-background">
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1084,7 +1120,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
return (
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1116,11 +1152,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
key={currentSessionId}
|
||||
currentSessionId={currentSessionId}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,29 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageHeader from './message/MessageHeader';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { TurnGroupingContext } from './lib/turns/types';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { copyMarkdownToClipboard, copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { toast } from 'sonner';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
|
||||
import { setContextObligatoryMessage } from '@/sync/session-actions';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
@@ -150,17 +156,15 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onUserAnimationConsumed,
|
||||
reviewTransferDirection = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||
const alwaysShowMessageActions = isMobile || isTablet;
|
||||
const canPinIntoContext = !isVSCodeRuntime();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
|
||||
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
||||
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
||||
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
|
||||
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
|
||||
|
||||
streamPerfCount('ui.chat_message.render');
|
||||
if (isInActiveTurn) {
|
||||
@@ -178,12 +182,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}))
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
||||
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
||||
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
|
||||
@@ -402,6 +400,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const timeInfo = message.info.time as { created?: number } | undefined;
|
||||
return typeof timeInfo?.created === 'number' ? timeInfo.created : null;
|
||||
}, [message.info.time]);
|
||||
const isPinnedIntoContext = useGlobalSessionsStore((state) => {
|
||||
const session = state.activeSessions.find((candidate) => candidate.id === sessionId)
|
||||
?? state.archivedSessions.find((candidate) => candidate.id === sessionId);
|
||||
return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id);
|
||||
});
|
||||
const [pinPending, setPinPending] = React.useState(false);
|
||||
const handleToggleContextPin = React.useCallback(async () => {
|
||||
if (!sessionId || !messageCreatedAt || pinPending) return;
|
||||
setPinPending(true);
|
||||
try {
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId);
|
||||
await setContextObligatoryMessage(sessionId, directory, {
|
||||
id: message.info.id,
|
||||
createdAt: messageCreatedAt,
|
||||
role: isUser ? 'user' : 'assistant',
|
||||
}, !isPinnedIntoContext);
|
||||
} catch (error) {
|
||||
console.error('[chat-message] failed to update context pin', error);
|
||||
toast.error(t('chat.messageBody.actions.contextPinFailed'));
|
||||
} finally {
|
||||
setPinPending(false);
|
||||
}
|
||||
}, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]);
|
||||
|
||||
const isMessageCompleted = React.useMemo(() => {
|
||||
if (isUser) return true;
|
||||
@@ -549,8 +570,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const shouldAnimateMessage = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
|
||||
}, [message.info, currentSessionId, isUser]);
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
|
||||
}, [message.info, isUser]);
|
||||
|
||||
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
|
||||
|
||||
@@ -562,6 +583,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const hasTurnGrouping = Boolean(turnGroupingContext);
|
||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
||||
|
||||
const previousIsHiddenUserMessage = React.useMemo(
|
||||
() => !isUser && isHiddenUserMessage(previousMessage, { planModeEnabled }),
|
||||
[isUser, planModeEnabled, previousMessage]
|
||||
);
|
||||
|
||||
const nextIsHiddenUserMessage = React.useMemo(
|
||||
() => !isUser && isHiddenUserMessage(nextMessage, { planModeEnabled }),
|
||||
[isUser, planModeEnabled, nextMessage]
|
||||
);
|
||||
|
||||
const isFollowedByAssistant = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
if (hasTurnGrouping) {
|
||||
@@ -740,7 +771,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const hasTextContent = messageTextContent.length > 0;
|
||||
|
||||
const handleCopyMessage = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(messageTextContent);
|
||||
let result;
|
||||
if (isUser) {
|
||||
result = await copyTextToClipboard(messageTextContent);
|
||||
} else {
|
||||
const { renderMarkdownSync } = await import('./markdown/markdownCore');
|
||||
result = await copyMarkdownToClipboard(messageTextContent, renderMarkdownSync(messageTextContent));
|
||||
}
|
||||
if (!result.ok) {
|
||||
return false;
|
||||
}
|
||||
@@ -753,14 +790,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const handleRevert = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, revertToMessage]);
|
||||
useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
// NEW: Fork handler
|
||||
const handleFork = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, forkFromMessage]);
|
||||
useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||
const isDefaultOpen = defaultOpenToolIds.has(toolId);
|
||||
@@ -975,7 +1012,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage
|
||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||
: 'pt-0';
|
||||
const userMessageRadius = 'var(--radius-xl)';
|
||||
@@ -985,8 +1022,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'group w-full',
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
|
||||
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-4') : assistantTopPaddingClass,
|
||||
isUser ? 'pb-0' : (isFollowedByAssistant || nextIsHiddenUserMessage) ? 'pb-0' : 'pb-2'
|
||||
)}
|
||||
id={`message-${message.info.id}`}
|
||||
data-message-id={message.info.id}
|
||||
@@ -1038,6 +1075,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
||||
@@ -1072,6 +1112,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode="external-actions"
|
||||
@@ -1084,17 +1127,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
)
|
||||
) : (
|
||||
<div className="relative">
|
||||
{shouldShowHeader && (
|
||||
<MessageHeader
|
||||
isUser={isUser}
|
||||
providerID={headerProviderID}
|
||||
agentName={headerAgentName}
|
||||
modelName={headerModelName}
|
||||
variant={headerVariant}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageBody
|
||||
sessionId={message.info.sessionID}
|
||||
messageId={message.info.id}
|
||||
@@ -1104,6 +1136,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
messageFinish={messageFinish}
|
||||
messageCompletedAt={messageCompletedAt ?? undefined}
|
||||
messageCreatedAt={messageCreatedAt ?? undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
isMobile={isMobile}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
hasTouchInput={hasTouchInput}
|
||||
@@ -1126,6 +1161,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
footerProviderID={headerProviderID}
|
||||
footerModelName={headerModelName}
|
||||
footerAgentName={headerAgentName}
|
||||
footerVariant={headerVariant}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -18,6 +19,7 @@ export interface CommandInfo {
|
||||
name: string;
|
||||
source: CommandSource;
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
agent?: string;
|
||||
model?: string;
|
||||
isBuiltIn?: boolean;
|
||||
@@ -170,6 +172,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [{ id: 'openchamber:craft-goal', name: 'craft-goal', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.craftGoalDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:schedule-task', name: 'schedule-task', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.scheduleTaskDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -187,14 +193,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
];
|
||||
const allCommands = [...builtInCommands, ...customCommands, ...skillCommands];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
? allCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
@@ -243,6 +246,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [{ id: 'openchamber:craft-goal', name: 'craft-goal', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.craftGoalDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:schedule-task', name: 'schedule-task', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.scheduleTaskDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
useDraftStarters,
|
||||
type ResolvedStarter,
|
||||
@@ -40,8 +41,8 @@ import {
|
||||
} from './useDraftStarters';
|
||||
|
||||
type DraftPresetChipsProps = {
|
||||
/** Called with the resolved text (command or skill invocation) when a chip is clicked. */
|
||||
onSubmit: (text: string) => void;
|
||||
/** Called with the resolved starter invocation when a chip is clicked. */
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
/** Extra classes for the wrapper (e.g. width/spacing per surface). */
|
||||
className?: string;
|
||||
};
|
||||
@@ -65,7 +66,7 @@ const PICKER_SECTIONS: { key: PinnableSection; headingKey: 'chat.draftStarters.s
|
||||
|
||||
const SortableChip: React.FC<{
|
||||
item: ResolvedStarter;
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
onRemove: () => void;
|
||||
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
|
||||
hideRemove?: boolean;
|
||||
@@ -90,7 +91,7 @@ const SortableChip: React.FC<{
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={() => onSubmit(item.submitText)}
|
||||
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"
|
||||
style={chipStyle}
|
||||
>
|
||||
@@ -115,7 +116,7 @@ const SortableChip: React.FC<{
|
||||
|
||||
const StarterGroup: React.FC<{
|
||||
items: ResolvedStarter[];
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
onRemove: (item: ResolvedStarter) => void;
|
||||
hideRemove?: boolean;
|
||||
}> = ({ items, onSubmit, onRemove, hideRemove }) => (
|
||||
@@ -254,7 +255,7 @@ const AddStarterPicker: React.FC<{
|
||||
* Reorder is constrained to within a chip's own group; cross-group hovers are
|
||||
* ignored.
|
||||
*/
|
||||
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||
const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
@@ -331,3 +332,8 @@ export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, cl
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = (props) => {
|
||||
const visible = useUIStore((state) => state.draftStartersVisible);
|
||||
return visible ? <DraftPresetChipsContent {...props} /> : null;
|
||||
};
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
attachMarkdownInteractions,
|
||||
applyMarkdownCodeBlockWrapState,
|
||||
decorateMarkdown,
|
||||
scheduleMarkdownCodeLineNumberSync,
|
||||
syncMarkdownCodeLineNumbers,
|
||||
getMarkdownCodeText,
|
||||
type DecorateContext,
|
||||
type DecorateLabels,
|
||||
type MermaidControlOptions,
|
||||
type MermaidRender,
|
||||
} from './markdown/decorate';
|
||||
import { findTextPosition } from './markdown/textPosition';
|
||||
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -227,21 +228,6 @@ const isLikelyFilePath = (value: string): boolean => {
|
||||
return isLikelyFilePathValue(parsed.path);
|
||||
};
|
||||
|
||||
const findTextPosition = (textNodes: Text[], targetOffset: number): { node: Text; offset: number } | null => {
|
||||
let currentOffset = 0;
|
||||
|
||||
for (const node of textNodes) {
|
||||
const nextOffset = currentOffset + node.data.length;
|
||||
if (targetOffset <= nextOffset) {
|
||||
return { node, offset: Math.max(0, targetOffset - currentOffset) };
|
||||
}
|
||||
currentOffset = nextOffset;
|
||||
}
|
||||
|
||||
const lastNode = textNodes.at(-1);
|
||||
return lastNode ? { node: lastNode, offset: lastNode.data.length } : null;
|
||||
};
|
||||
|
||||
const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
const tokenSpans = container.querySelectorAll<HTMLElement>(BLOCK_PATH_TOKEN_SELECTOR);
|
||||
for (const span of Array.from(tokenSpans)) {
|
||||
@@ -303,11 +289,14 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
const textNodes: Text[] = [];
|
||||
let currentNode = walker.nextNode();
|
||||
while (currentNode) {
|
||||
textNodes.push(currentNode as Text);
|
||||
const textNode = currentNode as Text;
|
||||
if (!textNode.parentElement?.closest('[data-md-code-line-number]')) {
|
||||
textNodes.push(textNode);
|
||||
}
|
||||
currentNode = walker.nextNode();
|
||||
}
|
||||
|
||||
const fullText = codeBlock.textContent ?? '';
|
||||
const fullText = getMarkdownCodeText(codeBlock);
|
||||
if (!fullText.includes('.')) {
|
||||
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
||||
continue;
|
||||
@@ -325,8 +314,8 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
}
|
||||
|
||||
for (const { start, end, raw } of matches.reverse()) {
|
||||
const startPosition = findTextPosition(textNodes, start);
|
||||
const endPosition = findTextPosition(textNodes, end);
|
||||
const startPosition = findTextPosition(textNodes, start, 'right');
|
||||
const endPosition = findTextPosition(textNodes, end, 'left');
|
||||
if (!startPosition || !endPosition) {
|
||||
continue;
|
||||
}
|
||||
@@ -754,69 +743,6 @@ const useMermaidInlineInteractions = ({
|
||||
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
|
||||
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
|
||||
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
|
||||
// matter the cadence — text always keeps up with the incoming stream.
|
||||
const TEXT_PACE_MS = 64;
|
||||
const PACE_BASELINE_MS = 24;
|
||||
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
|
||||
const TEXT_SNAP = /[\s.,!?;:)\]]/;
|
||||
|
||||
const paceStep = (remaining: number): number => {
|
||||
const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
|
||||
return Math.max(1, Math.round(base * PACE_RATIO));
|
||||
};
|
||||
|
||||
const nextRevealIndex = (text: string, start: number): number => {
|
||||
const end = Math.min(text.length, start + paceStep(text.length - start));
|
||||
for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
|
||||
if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
// Granular streaming reveal. Cheap because each step only re-runs the
|
||||
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
|
||||
// reconciliation of the markdown body.
|
||||
const usePacedText = (content: string, streaming: boolean): string => {
|
||||
const [shown, setShown] = React.useState<number>(() => (streaming ? 0 : content.length));
|
||||
const shownRef = React.useRef(shown);
|
||||
shownRef.current = shown;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!streaming || typeof window === 'undefined') {
|
||||
setShown(content.length);
|
||||
return;
|
||||
}
|
||||
if (shownRef.current > content.length) {
|
||||
setShown(content.length);
|
||||
}
|
||||
|
||||
let timer: number | null = null;
|
||||
const tick = () => {
|
||||
const current = Math.min(shownRef.current, content.length);
|
||||
if (current >= content.length) {
|
||||
timer = null;
|
||||
return;
|
||||
}
|
||||
setShown(nextRevealIndex(content, current));
|
||||
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
||||
};
|
||||
|
||||
if (shownRef.current < content.length) {
|
||||
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [content, streaming]);
|
||||
|
||||
if (!streaming) return content;
|
||||
return content.slice(0, Math.min(shown, content.length));
|
||||
};
|
||||
|
||||
// Mermaid layout is expensive; `decorate` would otherwise re-render every
|
||||
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
@@ -1020,9 +946,6 @@ const useMorphdomMarkdown = ({
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
scheduleMarkdownCodeLineNumberSync(target);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1054,24 +977,6 @@ const useMorphdomMarkdown = ({
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
}, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || typeof ResizeObserver === 'undefined') return;
|
||||
let frame: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
syncMarkdownCodeLineNumbers(target);
|
||||
});
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [containerRef]);
|
||||
};
|
||||
|
||||
const markdownContentClassName = (variant: MarkdownVariant): string =>
|
||||
@@ -1094,6 +999,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
onShowPopup,
|
||||
enableFileReferences = true,
|
||||
}) => {
|
||||
streamPerfCount('ui.markdown_renderer.render');
|
||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||
streamPerfObserve('ui.markdown_renderer.content_len', content.length);
|
||||
const currentTheme = useCurrentMermaidTheme();
|
||||
const { editor, runtime } = useRuntimeAPIs();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1106,7 +1014,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
}, [effectiveDirectory, openContextPreview]);
|
||||
|
||||
const live = isStreaming && !disableStreamAnimation;
|
||||
const pacedText = usePacedText(content, live);
|
||||
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
@@ -1127,7 +1034,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
||||
|
||||
@@ -12,9 +12,11 @@ import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
|
||||
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
@@ -385,7 +387,7 @@ type RenderEntry =
|
||||
previousMessage?: ChatMessageEntry;
|
||||
nextMessage?: ChatMessageEntry;
|
||||
}
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry };
|
||||
|
||||
type TurnUiState = { isExpanded: boolean };
|
||||
|
||||
@@ -469,6 +471,7 @@ MessageRow.displayName = 'MessageRow';
|
||||
interface TurnBlockProps {
|
||||
turn: TurnRecord;
|
||||
isLastTurn: boolean;
|
||||
nextEntryFirstMessage?: ChatMessageEntry;
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
@@ -488,6 +491,7 @@ interface TurnBlockProps {
|
||||
const TurnBlock = React.memo(({
|
||||
turn,
|
||||
isLastTurn,
|
||||
nextEntryFirstMessage,
|
||||
sessionIsWorking,
|
||||
defaultActivityExpanded,
|
||||
turnUiStates,
|
||||
@@ -503,6 +507,11 @@ const TurnBlock = React.memo(({
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}: TurnBlockProps) => {
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const userMessageHidden = React.useMemo(
|
||||
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
|
||||
[planModeEnabled, turn.userMessage]
|
||||
);
|
||||
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||
const handleToggleTurnGroup = React.useCallback(() => {
|
||||
onToggleTurnGroup(turn.turnId);
|
||||
@@ -682,7 +691,7 @@ const TurnBlock = React.memo(({
|
||||
: (typeof messageIndex === 'number' && messageIndex > 0
|
||||
? messageOrder.ordered[messageIndex - 1]
|
||||
: undefined));
|
||||
const nextMessage = undefined;
|
||||
const nextMessage = isAssistantMessage && isLastAssistant ? nextEntryFirstMessage : undefined;
|
||||
|
||||
const turnGroupingContext = isAssistantMessage
|
||||
? {
|
||||
@@ -735,6 +744,7 @@ const TurnBlock = React.memo(({
|
||||
[
|
||||
getAnimationHandlers,
|
||||
isLastTurn,
|
||||
nextEntryFirstMessage,
|
||||
messageOrder.lookup,
|
||||
messageOrder.ordered,
|
||||
onMessageContentChange,
|
||||
@@ -772,7 +782,11 @@ const TurnBlock = React.memo(({
|
||||
}, [turn, visibleAssistantMessages]);
|
||||
|
||||
return (
|
||||
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
||||
<TurnItem
|
||||
turn={renderableTurn}
|
||||
stickyUserHeader={stickyUserHeader && !userMessageHidden}
|
||||
renderMessage={renderMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -871,6 +885,7 @@ const MessageListEntry = React.memo(({
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}: MessageListEntryProps) => {
|
||||
streamPerfCount('ui.message_list_entry.render');
|
||||
if (entry.kind === 'ungrouped') {
|
||||
return (
|
||||
<UngroupedMessageRow
|
||||
@@ -893,6 +908,7 @@ const MessageListEntry = React.memo(({
|
||||
<TurnBlock
|
||||
turn={entry.turn}
|
||||
isLastTurn={entry.isLastTurn}
|
||||
nextEntryFirstMessage={entry.nextEntryFirstMessage}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
@@ -1204,12 +1220,14 @@ const StreamingTailContent: React.FC<{
|
||||
reviewTransferDirection,
|
||||
}) => {
|
||||
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId,
|
||||
liveParts,
|
||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||
showTurnChangedFiles,
|
||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
|
||||
mergeHiddenUserTurns: { planModeEnabled },
|
||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]);
|
||||
|
||||
return (
|
||||
<MessageListEntry
|
||||
@@ -1247,6 +1265,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
scrollRef,
|
||||
directory,
|
||||
}, ref) => {
|
||||
streamPerfMark('react.message_list_render');
|
||||
streamPerfCount('ui.message_list.render');
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
@@ -1358,10 +1377,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
});
|
||||
}), [baseDisplayMessages, retryOverlay]);
|
||||
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
||||
sessionKey,
|
||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||
showTurnChangedFiles,
|
||||
planModeEnabled,
|
||||
});
|
||||
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
|
||||
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
|
||||
@@ -1439,11 +1460,37 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
streamPerfCount('ui.message_list.render.streaming');
|
||||
}
|
||||
|
||||
const historyEntries = staticRenderEntries;
|
||||
// All surfaces virtualize with @tanstack/react-virtual (see the engine
|
||||
// note at the top of the file). An unvirtualized list is kept only for
|
||||
// tiny histories where windowing overhead is not worth it.
|
||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
// Depend on the trailing entry's first message (stable while its assistant
|
||||
// streams), not the trailing entry itself, so streaming updates do not
|
||||
// recreate every static entry and re-render every turn block.
|
||||
const trailingEntryFirstMessage = trailingStreamingEntry
|
||||
? (trailingStreamingEntry.kind === 'turn' ? trailingStreamingEntry.turn.userMessage : trailingStreamingEntry.message)
|
||||
: undefined;
|
||||
const historyEntries = React.useMemo<RenderEntry[]>(() => {
|
||||
return staticRenderEntries.map((entry, index) => {
|
||||
if (entry.kind !== 'turn') {
|
||||
return entry;
|
||||
}
|
||||
const nextEntryFirstMessage = index < staticRenderEntries.length - 1
|
||||
? (() => {
|
||||
const nextEntry = staticRenderEntries[index + 1];
|
||||
return nextEntry.kind === 'turn' ? nextEntry.turn.userMessage : nextEntry.message;
|
||||
})()
|
||||
: trailingEntryFirstMessage;
|
||||
if (!nextEntryFirstMessage) {
|
||||
return entry;
|
||||
}
|
||||
return { ...entry, nextEntryFirstMessage };
|
||||
});
|
||||
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
||||
// Mobile always starts with the same virtualized engine it will use after
|
||||
// pagination. Switching a short list from normal DOM to TanStack during a
|
||||
// prepend remounts the history subtree, and the newly enabled end-anchored
|
||||
// virtualizer initializes at the bottom before it has prior keyed state.
|
||||
// Desktop keeps the small-list threshold where that transition is not tied
|
||||
// to the explicit mobile load-older interaction.
|
||||
const shouldVirtualizeHistory = isMobileSurfaceRuntime()
|
||||
|| historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
||||
@@ -1544,7 +1591,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number) => {
|
||||
if (index < 0 || index >= historyEntries.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -1558,7 +1605,11 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' });
|
||||
// Smooth scrolling can stop at a stale offset while unmounted,
|
||||
// variable-height rows replace estimates with real measurements. Use
|
||||
// exact auto-reconciliation; mounted targets still take the smooth DOM
|
||||
// path below.
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'auto' });
|
||||
return true;
|
||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
||||
|
||||
@@ -1608,7 +1659,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollHistoryIndexIntoView(index, behavior);
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
},
|
||||
|
||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||
@@ -1622,7 +1673,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|| (
|
||||
trailingStreamingEntry !== undefined && index >= historyEntries.length
|
||||
? false
|
||||
: scrollHistoryIndexIntoView(index, behavior)
|
||||
: scrollHistoryIndexIntoView(index)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1731,7 +1782,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
if (!applyAnchor()) {
|
||||
const index = messageIndexMap.get(anchor.messageId);
|
||||
if (typeof index === 'number' && index < historyEntries.length) {
|
||||
return scrollHistoryIndexIntoView(index, 'auto');
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1745,7 +1796,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
// Overshoot so the browser clamps to the exact fractional
|
||||
// maximum (scrollHeight is integer-rounded) — see useChatAutoFollow.
|
||||
container.scrollTop = container.scrollHeight + 4096;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
||||
import { mergeLiveSessionWithGlobalSession, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
|
||||
interface MobileSessionStatusBarProps {
|
||||
onSessionSwitch?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface SessionWithStatus extends Session {
|
||||
_statusType?: 'busy' | 'retry' | 'idle';
|
||||
_hasRunningChildren?: boolean;
|
||||
_runningChildrenCount?: number;
|
||||
_childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
||||
}
|
||||
|
||||
// Cross-project session source. Mirrors the dedicated MobileSessionsSheet:
|
||||
// global sessions cover all directories (even unbootstrapped ones), while the
|
||||
// live aggregate (`useAllLiveSessions`) surfaces fresher data and every
|
||||
// bootstrapped directory. Merging both makes other projects' sessions appear.
|
||||
function useAllProjectSessions(): Session[] {
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
return React.useMemo(() => {
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => {
|
||||
const liveSession = liveById.get(session.id);
|
||||
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
|
||||
});
|
||||
const seen = new Set(merged.map((session) => session.id));
|
||||
for (const session of liveSessions) {
|
||||
if (!seen.has(session.id)) merged.push(session);
|
||||
}
|
||||
return merged;
|
||||
}, [globalActiveSessions, liveSessions]);
|
||||
}
|
||||
|
||||
// Max sessions shown per (filtered) project list - a "recent" cap applied
|
||||
// after filtering, so each project view shows at most this many.
|
||||
const MAX_RECENT_SESSIONS = 25;
|
||||
|
||||
// Normalize path for comparison
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
// A session's directory, mirroring the store's canonical resolution.
|
||||
const sessionDirectory = (session: Session): string => {
|
||||
const record = session as Session & {
|
||||
directory?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
return normalize(record.directory ?? record.project?.worktree ?? '');
|
||||
};
|
||||
|
||||
// Prefix-match used to group a session under a project root or worktree.
|
||||
const pathBelongsToRoot = (path: string, root: string): boolean => {
|
||||
const p = normalize(path);
|
||||
const r = normalize(root);
|
||||
return Boolean(p && r && (p === r || p.startsWith(`${r}/`)));
|
||||
};
|
||||
|
||||
function useSessionGrouping(
|
||||
sessions: Session[],
|
||||
sessionStatus: Record<string, { type: string }> | undefined
|
||||
) {
|
||||
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||
|
||||
const parentChildMap = React.useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
const allIds = new Set(sessions.map((s) => s.id));
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const parentID = (session as { parentID?: string }).parentID;
|
||||
if (parentID && allIds.has(parentID)) {
|
||||
map.set(parentID, [...(map.get(parentID) || []), session]);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [sessions]);
|
||||
|
||||
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
|
||||
const status = sessionStatus?.[sessionId];
|
||||
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||
return 'idle';
|
||||
}, [sessionStatus]);
|
||||
|
||||
const hasRunningChildren = React.useCallback((sessionId: string): boolean => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children.some((child) => getStatusType(child.id) !== 'idle');
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children.filter((child) => getStatusType(child.id) !== 'idle').length;
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
|
||||
const children = parentChildMap.get(sessionId) || [];
|
||||
return children
|
||||
.filter((child) => getStatusType(child.id) !== 'idle')
|
||||
.map((child) => ({ session: child, isRunning: true }))
|
||||
.slice(0, 3);
|
||||
}, [parentChildMap, getStatusType]);
|
||||
|
||||
const processedSessions = React.useMemo(() => {
|
||||
const sessionIds = new Set(sessions.map((s) => s.id));
|
||||
const topLevel = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string }).parentID;
|
||||
return !parentID || !sessionIds.has(parentID);
|
||||
});
|
||||
|
||||
const running: SessionWithStatus[] = [];
|
||||
const viewed: SessionWithStatus[] = [];
|
||||
|
||||
topLevel.forEach((session) => {
|
||||
const statusType = getStatusType(session.id);
|
||||
const hasRunning = hasRunningChildren(session.id);
|
||||
const attention = (unseenCounts[session.id] ?? 0) > 0;
|
||||
|
||||
const enriched: SessionWithStatus = {
|
||||
...session,
|
||||
_statusType: statusType,
|
||||
_hasRunningChildren: hasRunning,
|
||||
_runningChildrenCount: getRunningChildrenCount(session.id),
|
||||
_childIndicators: getChildIndicators(session.id),
|
||||
};
|
||||
|
||||
if (statusType !== 'idle' || hasRunning) {
|
||||
running.push(enriched);
|
||||
} else if (attention) {
|
||||
running.push(enriched);
|
||||
} else {
|
||||
viewed.push(enriched);
|
||||
}
|
||||
});
|
||||
|
||||
const sortByUpdated = (a: Session, b: Session) => {
|
||||
const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||
const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||
return bTime - aTime;
|
||||
};
|
||||
|
||||
running.sort(sortByUpdated);
|
||||
viewed.sort(sortByUpdated);
|
||||
|
||||
return [...running, ...viewed];
|
||||
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
|
||||
|
||||
const totalRunning = processedSessions.reduce((sum, s) => {
|
||||
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
||||
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
||||
}, 0);
|
||||
|
||||
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
|
||||
|
||||
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
||||
}
|
||||
|
||||
function useSessionHelpers() {
|
||||
const getSessionTitle = React.useCallback((session: Session): string => {
|
||||
const title = session.title;
|
||||
if (title && title.trim()) return title;
|
||||
return 'New session';
|
||||
}, []);
|
||||
|
||||
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||
const needsAttention = React.useCallback((sessionId: string): boolean => {
|
||||
return (unseenCounts[sessionId] ?? 0) > 0;
|
||||
}, [unseenCounts]);
|
||||
|
||||
return { getSessionTitle, needsAttention };
|
||||
}
|
||||
|
||||
// Per-project status indicators (running / unread) for the filter chips.
|
||||
function useProjectStatus(
|
||||
sessions: Session[],
|
||||
sessionStatus: Record<string, { type: string }> | undefined,
|
||||
currentSessionId: string | null
|
||||
) {
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
||||
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
||||
|
||||
return React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
||||
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
||||
const status = sessionStatus?.[sessionId];
|
||||
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
const projectRoot = normalize(projectPath);
|
||||
if (!projectRoot) return { hasRunning: false, hasUnread: false };
|
||||
|
||||
const dirs: string[] = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized && normalized !== projectRoot) dirs.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let hasRunning = false;
|
||||
let hasUnread = false;
|
||||
|
||||
for (const dir of dirs) {
|
||||
for (const session of getSessionsByDirectory(dir)) {
|
||||
if (!session?.id || seen.has(session.id)) continue;
|
||||
seen.add(session.id);
|
||||
|
||||
if (getStatusType(session.id) !== 'idle') hasRunning = true;
|
||||
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) hasUnread = true;
|
||||
if (hasRunning && hasUnread) break;
|
||||
}
|
||||
if (hasRunning && hasUnread) break;
|
||||
}
|
||||
|
||||
return { hasRunning, hasUnread };
|
||||
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
|
||||
}
|
||||
|
||||
// Resolves the project's root directories (root + known worktrees) for
|
||||
// prefix-matching sessions, mirroring the dedicated MobileSessionsSheet.
|
||||
function useProjectRootsResolver() {
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
|
||||
return React.useCallback((project: ProjectEntry): string[] => {
|
||||
const projectRoot = normalize(project.path);
|
||||
const roots = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized) roots.push(normalized);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}, [availableWorktreesByProject]);
|
||||
}
|
||||
|
||||
function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) {
|
||||
if (isRunning) {
|
||||
return <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-[var(--status-info)]" />;
|
||||
}
|
||||
if (needsAttention) {
|
||||
return <div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />;
|
||||
}
|
||||
return <div className="h-2 w-2 rounded-full border border-[var(--surface-mutedForeground)]" />;
|
||||
}
|
||||
|
||||
function RunningIndicator({ count }: { count: number }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[13px] text-[var(--status-info)]">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadIndicator({ count }: { count: number }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-[13px] text-[var(--status-error)]">
|
||||
<div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A single session row sized for comfortable touch.
|
||||
function SessionItem({
|
||||
session,
|
||||
isCurrent,
|
||||
getSessionTitle,
|
||||
onClick,
|
||||
needsAttention,
|
||||
}: {
|
||||
session: SessionWithStatus;
|
||||
isCurrent: boolean;
|
||||
getSessionTitle: (s: Session) => string;
|
||||
onClick: () => void;
|
||||
needsAttention: (sessionId: string) => boolean;
|
||||
}) {
|
||||
const attention = needsAttention(session.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors min-h-[56px]",
|
||||
"active:bg-[var(--interactive-selection)]",
|
||||
isCurrent ? "bg-[color-mix(in_srgb,var(--interactive-selection)_40%,transparent)]" : "hover:bg-[var(--interactive-hover)]"
|
||||
)}
|
||||
>
|
||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<StatusIndicator isRunning={session._statusType !== 'idle'} needsAttention={attention} />
|
||||
</span>
|
||||
|
||||
<span className={cn(
|
||||
"flex-1 truncate text-[15px] leading-tight",
|
||||
isCurrent ? "font-semibold text-[var(--surface-foreground)]" : "text-[var(--surface-foreground)]"
|
||||
)}>
|
||||
{getSessionTitle(session)}
|
||||
</span>
|
||||
|
||||
{(session._runningChildrenCount ?? 0) > 0 && (
|
||||
<span className="flex flex-shrink-0 items-center gap-1 text-[12px] text-[var(--status-info)]">
|
||||
<Icon name="loader-4" className="h-3 w-3 animate-spin" />
|
||||
{session._runningChildrenCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-[var(--primary-base)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// A project filter pill sized for touch. Selecting it filters
|
||||
// the session list; it does NOT switch the active project.
|
||||
interface ProjectFilterChipProps {
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
project?: Pick<ProjectEntry, 'id' | 'iconImage'> | null;
|
||||
iconOptions?: React.ComponentProps<typeof ProjectIconImage>['options'];
|
||||
iconBackground?: string | null;
|
||||
colorVar?: string | null;
|
||||
isActive: boolean;
|
||||
status?: { hasRunning: boolean; hasUnread: boolean };
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ProjectFilterChip({
|
||||
label,
|
||||
icon,
|
||||
project,
|
||||
iconOptions,
|
||||
iconBackground,
|
||||
colorVar,
|
||||
isActive,
|
||||
status,
|
||||
onClick,
|
||||
}: ProjectFilterChipProps) {
|
||||
const projectIconName = icon ? PROJECT_ICON_MAP[icon] : null;
|
||||
const fallbackIcon = projectIconName ? (
|
||||
<Icon name={projectIconName} className="h-4 w-4" style={!isActive && colorVar ? { color: colorVar } : undefined} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-[40px] shrink-0 select-none items-center gap-1.5 rounded-full border px-3.5 text-[13px] leading-none whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-transparent bg-[var(--primary-base)] text-[var(--primary-foreground)] font-medium"
|
||||
: "border-[var(--interactive-border)] bg-[var(--surface-subtle)] text-[var(--surface-foreground)] active:bg-[var(--interactive-hover)]"
|
||||
)}
|
||||
>
|
||||
{status && (status.hasRunning || status.hasUnread) && !isActive && (
|
||||
status.hasRunning
|
||||
? <Icon name="loader-4" className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />
|
||||
: <span className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
|
||||
)}
|
||||
|
||||
{project?.iconImage ? (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
|
||||
>
|
||||
<ProjectIconImage
|
||||
project={project}
|
||||
options={iconOptions}
|
||||
className="h-full w-full object-contain"
|
||||
fallback={fallbackIcon}
|
||||
/>
|
||||
</span>
|
||||
) : fallbackIcon}
|
||||
|
||||
<span className="max-w-[140px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// The chip that lives in the composer footer and toggles the slide-up sheet.
|
||||
// This is the only persistent affordance; there is no longer a permanent bar.
|
||||
interface MobileSessionPanelTriggerProps {
|
||||
footerIconButtonClass: string;
|
||||
iconSizeClass: string;
|
||||
}
|
||||
|
||||
export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps> = ({
|
||||
footerIconButtonClass,
|
||||
iconSizeClass,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
|
||||
// Ensure the cross-project session list is loaded once, so the panel reflects
|
||||
// every project, not just the active directory.
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
void ensureGlobalSessionsLoaded();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
footerIconButtonClass,
|
||||
'rounded-md relative hover:bg-[var(--interactive-hover)]',
|
||||
open && 'text-[var(--primary-base)]'
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onClick={() => setOpen(!open)}
|
||||
title={t('mobile.sessions.search.section.sessions')}
|
||||
aria-label={t('mobile.sessions.search.section.sessions')}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Icon name="stack" className={cn(iconSizeClass)} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||
onSessionSwitch,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const sessions = useAllProjectSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionStatus = useAllSessionStatuses();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const open = useUIStore((state) => state.mobileSessionPanelOpen);
|
||||
const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
|
||||
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
|
||||
const { getSessionTitle, needsAttention } = useSessionHelpers();
|
||||
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
||||
const resolveProjectRoots = useProjectRootsResolver();
|
||||
|
||||
// Project filter, persisted in the UI store so the choice survives closing and
|
||||
// reopening the sheet. Defaults to "All" so sessions from every project are
|
||||
// visible regardless of which session is currently selected.
|
||||
const filterProjectId = useUIStore((state) => state.mobileSessionFilterProjectId);
|
||||
const setFilterProjectId = useUIStore((state) => state.setMobileSessionFilterProjectId);
|
||||
|
||||
// Refresh the cross-project session list when the panel opens (mirrors the
|
||||
// dedicated MobileSessionsSheet). The active-directory sync only upserts the
|
||||
// current project's sessions, so other projects need this global load.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
void refreshGlobalSessions(sessions);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const formatProjectLabel = React.useCallback((project: ProjectEntry): string => {
|
||||
return project.label?.trim()
|
||||
|| formatDirectoryName(project.path, homeDirectory)
|
||||
|| project.path;
|
||||
}, [homeDirectory]);
|
||||
|
||||
// Filter sessions by the selected project (root + worktrees), using the
|
||||
// store's canonical directory keying.
|
||||
const filteredSessions = React.useMemo(() => {
|
||||
if (!filterProjectId) return sortedSessions;
|
||||
const project = projects.find((p) => p.id === filterProjectId);
|
||||
if (!project) return sortedSessions;
|
||||
const roots = resolveProjectRoots(project);
|
||||
return sortedSessions.filter((session) => {
|
||||
const dir = sessionDirectory(session);
|
||||
return roots.some((root) => pathBelongsToRoot(dir, root));
|
||||
});
|
||||
}, [sortedSessions, filterProjectId, projects, resolveProjectRoots]);
|
||||
|
||||
// Cap to the most recent N (already sorted running-first, then by updated).
|
||||
const visibleSessions = React.useMemo(
|
||||
() => filteredSessions.slice(0, MAX_RECENT_SESSIONS),
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
const handleSessionClick = (session: SessionWithStatus) => {
|
||||
setCurrentSession(session.id, sessionDirectory(session) || null);
|
||||
onSessionSwitch?.(session.id);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// "+" — start a new session draft. Target the project selected in the filter;
|
||||
// for "All", use the most recently active session's directory, falling back to
|
||||
// the store's own default target when there are no sessions.
|
||||
const handleNewChat = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
if (filterProjectId) {
|
||||
const project = projects.find((p) => p.id === filterProjectId);
|
||||
if (project) {
|
||||
openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mostRecent = [...sessions].sort((a, b) => {
|
||||
const aTime = (a as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||
const bTime = (b as { time?: { updated?: number } }).time?.updated ?? 0;
|
||||
return bTime - aTime;
|
||||
})[0];
|
||||
const directory = mostRecent ? sessionDirectory(mostRecent) : '';
|
||||
openNewSessionDraft(directory ? { directoryOverride: directory } : undefined);
|
||||
}, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]);
|
||||
|
||||
const renderHeader = React.useCallback(() => (
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-4 pb-2">
|
||||
<h2 className="text-[16px] font-semibold text-[var(--surface-foreground)]">
|
||||
{t('mobile.sessions.search.section.sessions')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<RunningIndicator count={totalRunning} />
|
||||
<UnreadIndicator count={totalUnread} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
aria-label={t('mobile.sessions.newChat')}
|
||||
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length > 1 && (
|
||||
<div
|
||||
className="flex items-center gap-2 overflow-x-auto border-t border-[color-mix(in_srgb,var(--interactive-border)_40%,transparent)] px-4 py-2.5 scrollbar-none"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<ProjectFilterChip
|
||||
label={t('chat.modelControls.modeValue.all')}
|
||||
isActive={filterProjectId === null}
|
||||
onClick={() => setFilterProjectId(null)}
|
||||
/>
|
||||
{projects.map((project) => (
|
||||
<ProjectFilterChip
|
||||
key={project.id}
|
||||
label={formatProjectLabel(project)}
|
||||
icon={project.icon}
|
||||
project={{ id: project.id, iconImage: project.iconImage ?? null }}
|
||||
iconOptions={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
iconBackground={project.iconBackground ?? null}
|
||||
colorVar={project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null}
|
||||
isActive={filterProjectId === project.id}
|
||||
status={getProjectStatus(project.path)}
|
||||
onClick={() => setFilterProjectId(project.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
|
||||
|
||||
if (!isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t('mobile.sessions.search.section.sessions')}
|
||||
renderHeader={renderHeader}
|
||||
className="h-[72vh]"
|
||||
contentMaxHeightClassName="max-h-full"
|
||||
>
|
||||
<div className="flex min-h-full flex-col gap-0.5">
|
||||
{visibleSessions.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center py-10 text-[13px] text-[var(--surface-mutedForeground)]">
|
||||
<span>{t('chat.mobileStatus.noSessionsInProject')}</span>
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
isCurrent={session.id === currentSessionId}
|
||||
getSessionTitle={getSessionTitle}
|
||||
onClick={() => handleSessionClick(session)}
|
||||
needsAttention={needsAttention}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { focusChatInput } from './composer/editor/dom';
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import {
|
||||
@@ -29,9 +30,8 @@ import { useContextStore } from '@/stores/contextStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
|
||||
import { useSessionMessages, useSessionRenderable } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
@@ -479,10 +479,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
// Restore focus to chat input when model selector closes
|
||||
if (wasOpen && !isCompact) {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
});
|
||||
requestAnimationFrame(focusChatInput);
|
||||
}
|
||||
}
|
||||
}, [isModelSelectorOpen, isCompact]);
|
||||
@@ -493,10 +490,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
if (!isAgentSelectorOpen) {
|
||||
setAgentSearchQuery('');
|
||||
if (!isCompact) {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
});
|
||||
requestAnimationFrame(focusChatInput);
|
||||
}
|
||||
}
|
||||
}, [isAgentSelectorOpen, isCompact]);
|
||||
@@ -646,11 +640,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||
const hasRenderableCurrentSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
const hasRenderableCurrentSessionSnapshot = useSessionRenderable(
|
||||
currentSessionId ?? '',
|
||||
currentSessionDirectory ?? undefined,
|
||||
);
|
||||
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
|
||||
@@ -1268,10 +1259,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
closeMobilePanel();
|
||||
}
|
||||
// Restore focus to chat input after model selection.
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
});
|
||||
requestAnimationFrame(focusChatInput);
|
||||
} catch (error) {
|
||||
console.error('[ModelControls] Handle model change error:', error);
|
||||
}
|
||||
@@ -1609,13 +1597,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const focusMobileComposer = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => {
|
||||
const result = applyModelSelectionWithVariant(providerId, modelId, variant);
|
||||
if (result !== 'applied') {
|
||||
@@ -1629,7 +1610,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
setExpandedMobileModelKey(null);
|
||||
closeMobilePanel();
|
||||
focusMobileComposer();
|
||||
requestAnimationFrame(focusChatInput);
|
||||
};
|
||||
|
||||
const openMobileVariantOverflow = (providerId: string, modelId: string) => {
|
||||
@@ -1965,10 +1946,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
closeMobilePanel();
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
});
|
||||
requestAnimationFrame(focusChatInput);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -90,7 +90,6 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
||||
return;
|
||||
}
|
||||
store.navigateToDiff(file.relativePath, openStagedDiff);
|
||||
store.setRightSidebarOpen(false);
|
||||
};
|
||||
|
||||
const fileCount = gitChangedFiles.length;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
|
||||
|
||||
describe('getVisiblePermissionPatterns', () => {
|
||||
test('omits a pattern already rendered as the bash command', () => {
|
||||
const command = 'bunx eslint "src/components/session/SessionSidebar.tsx"';
|
||||
|
||||
expect(getVisiblePermissionPatterns([command], command)).toEqual([]);
|
||||
});
|
||||
|
||||
test('preserves distinct permission patterns', () => {
|
||||
const command = 'bunx eslint "src/components/session/SessionSidebar.tsx"';
|
||||
|
||||
expect(getVisiblePermissionPatterns(['bunx eslint *', command], command)).toEqual(['bunx eslint *']);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
|
||||
|
||||
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
@@ -123,6 +124,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
|
||||
const toolName = permission.permission || 'unknown';
|
||||
const tool = toolName.toLowerCase();
|
||||
const isBashTool = tool === 'bash' || tool === 'shell' || tool === 'shell_command';
|
||||
|
||||
const getMeta = (key: string, fallback: string = ''): string => {
|
||||
const val = permission.metadata[key];
|
||||
@@ -137,11 +139,14 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
return Boolean(val);
|
||||
};
|
||||
const displayToolName = getToolDisplayName(toolName);
|
||||
const bashCommand = isBashTool
|
||||
? getMeta('command') || getMeta('cmd') || getMeta('script')
|
||||
: '';
|
||||
const visiblePatterns = getVisiblePermissionPatterns(permission.patterns, bashCommand);
|
||||
|
||||
const renderToolContent = () => {
|
||||
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||
const command = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||
if (isBashTool) {
|
||||
const description = getMeta('description');
|
||||
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
||||
const timeout = getMetaNum('timeout');
|
||||
@@ -162,11 +167,11 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{}
|
||||
{command && (
|
||||
{bashCommand && (
|
||||
<div>
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
code={command}
|
||||
code={bashCommand}
|
||||
style={PERMISSION_BASH_CUSTOM_STYLE}
|
||||
codeStyle={PERMISSION_BASH_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
@@ -333,11 +338,11 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
|
||||
{}
|
||||
<div className="px-2 py-2">
|
||||
{permission.patterns.length > 0 && (
|
||||
{visiblePatterns.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.patterns')}</div>
|
||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{permission.patterns.join(", ")}
|
||||
{visiblePatterns.join(", ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -24,12 +24,12 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
message: QueuedMessage;
|
||||
sessionId: string;
|
||||
target: MessageQueueTarget;
|
||||
onEdit: (message: QueuedMessage) => void;
|
||||
onSend: (message: QueuedMessage) => void;
|
||||
}
|
||||
|
||||
const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => {
|
||||
const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessageChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
|
||||
@@ -89,7 +89,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromQueue(sessionId, message.id)}
|
||||
onClick={() => removeFromQueue(target, message.id)}
|
||||
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
||||
aria-label={t('chat.queuedMessage.removeAria')}
|
||||
>
|
||||
@@ -111,13 +111,16 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
|
||||
const queueKey = target ? getMessageQueueKey(target) : null;
|
||||
const queuedMessages = useMessageQueueStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!currentSessionId) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
||||
if (!queueKey) return EMPTY_QUEUE;
|
||||
return state.queuedMessages[queueKey] ?? EMPTY_QUEUE;
|
||||
},
|
||||
[currentSessionId]
|
||||
[queueKey]
|
||||
)
|
||||
);
|
||||
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
||||
@@ -132,14 +135,14 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !currentSessionId) return;
|
||||
reorderQueue(currentSessionId, String(active.id), String(over.id));
|
||||
}, [currentSessionId, reorderQueue]);
|
||||
if (!over || active.id === over.id || !target) return;
|
||||
reorderQueue(target, String(active.id), String(over.id));
|
||||
}, [target, reorderQueue]);
|
||||
|
||||
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||
if (!currentSessionId) return;
|
||||
if (!target) return;
|
||||
|
||||
const popped = popToInput(currentSessionId, message.id);
|
||||
const popped = popToInput(target, message.id);
|
||||
if (popped) {
|
||||
if (popped.attachments && popped.attachments.length > 0) {
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
@@ -147,13 +150,13 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
}
|
||||
}, [currentSessionId, popToInput, onEditMessage]);
|
||||
}, [target, popToInput, onEditMessage]);
|
||||
|
||||
const handleSend = React.useCallback((message: QueuedMessage) => {
|
||||
onSendMessage(message.id);
|
||||
}, [onSendMessage]);
|
||||
|
||||
if (queuedMessages.length === 0 || !currentSessionId) {
|
||||
if (queuedMessages.length === 0 || !target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -180,7 +183,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
target={target}
|
||||
onEdit={handleEdit}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ s
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
{/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
|
||||
<div className="-mt-6" aria-label={t('chat.recap.aria')}>
|
||||
<div aria-label={t('chat.recap.aria')}>
|
||||
<span className={`typography-meta text-muted-foreground/70 ${isMobile ? 'line-clamp-4' : 'line-clamp-2'}`}>
|
||||
<span className="italic text-muted-foreground/50">{t('chat.recap.label')} </span>
|
||||
{visibleRecap}
|
||||
|
||||
@@ -133,6 +133,8 @@ interface StatusRowProps {
|
||||
showAssistantStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -150,11 +152,19 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
showAssistantStatus = true,
|
||||
showTodos = true,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
@@ -166,8 +176,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
|
||||
[currentSessionId, showTodos],
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
@@ -293,7 +305,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mb-1", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE}>
|
||||
<div
|
||||
// Mobile: breathing room between the last message and the agent status
|
||||
// line — without it the "<model> is running…" row sits flush against
|
||||
// the message above.
|
||||
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
|
||||
style={STATUS_ROW_CONTAINER_STYLE}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: Abort status | Working placeholder | leftAccessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
@@ -313,6 +331,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
isWaitingForPermission={isWaitingForPermission}
|
||||
retryInfo={retryInfo}
|
||||
agentName={agentName}
|
||||
modelName={modelName}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { StatusRow } from './StatusRow';
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,19 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId]),
|
||||
);
|
||||
const { working } = useAssistantStatus();
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
|
||||
const modelDisplayName = React.useMemo(() => {
|
||||
if (!activeModel) {
|
||||
return null;
|
||||
}
|
||||
const provider = providers.length > 0
|
||||
? providers.find((candidate) => candidate.id === activeModel.providerId)
|
||||
: undefined;
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
||||
|
||||
@@ -37,6 +49,8 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
showAssistantStatus
|
||||
showTodos={false}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
providerId={activeModel?.providerId ?? null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
}
|
||||
|
||||
store.navigateToDiff(relativePath, false, 'turn');
|
||||
store.setRightSidebarOpen(false);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from '../commandAutocompleteItems';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
source: 'openchamber' | 'opencode' | 'skill';
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
isBuiltIn?: boolean;
|
||||
isSkill?: boolean;
|
||||
}
|
||||
|
||||
describe('mergeCommandAutocompleteItems', () => {
|
||||
test('retains the discovered skill and command search metadata for #1550', () => {
|
||||
const commands: Item[] = [{
|
||||
name: 'grill-with-docs',
|
||||
source: 'opencode',
|
||||
description: 'Plugin command description',
|
||||
isSkill: true,
|
||||
}];
|
||||
const skills: Item[] = [{
|
||||
name: 'grill-with-docs',
|
||||
source: 'skill',
|
||||
description: 'Canonical skill description',
|
||||
isSkill: true,
|
||||
}];
|
||||
|
||||
const merged = mergeCommandAutocompleteItems([], commands, skills);
|
||||
|
||||
expect(merged).toEqual([{
|
||||
...skills[0],
|
||||
searchAliases: ['Plugin command description'],
|
||||
}]);
|
||||
expect(commandMatchesSearch(merged[0], 'plugin command')).toBe(true);
|
||||
});
|
||||
|
||||
test('built-ins win collisions with commands and skills without losing search aliases', () => {
|
||||
const builtIn: Item = {
|
||||
name: 'summary',
|
||||
source: 'openchamber',
|
||||
description: 'Summarize this session',
|
||||
isBuiltIn: true,
|
||||
};
|
||||
const command: Item = {
|
||||
name: 'summary',
|
||||
source: 'opencode',
|
||||
description: 'Plugin session digest',
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'summary',
|
||||
source: 'skill',
|
||||
description: 'Skill session recap',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [command], [skill])).toEqual([{
|
||||
...builtIn,
|
||||
searchAliases: ['Plugin session digest', 'Skill session recap'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('OpenCode built-ins also win collisions with discovered skills', () => {
|
||||
const builtIn: Item = {
|
||||
name: 'review',
|
||||
source: 'opencode',
|
||||
description: 'Review workspace changes',
|
||||
isBuiltIn: true,
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'review',
|
||||
source: 'skill',
|
||||
description: 'Review skill',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [builtIn], [skill])).toEqual([{
|
||||
...builtIn,
|
||||
searchAliases: ['Review skill'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('deduplicates every pairwise source collision by executable precedence', () => {
|
||||
const builtIn: Item = { name: 'compact', source: 'openchamber', isBuiltIn: true };
|
||||
const command: Item = { name: 'compact', source: 'opencode' };
|
||||
const skill: Item = { name: 'compact', source: 'skill', isSkill: true };
|
||||
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [command], [])[0]).toBe(builtIn);
|
||||
expect(mergeCommandAutocompleteItems([builtIn], [], [skill])[0]).toBe(builtIn);
|
||||
expect(mergeCommandAutocompleteItems([], [command], [skill])[0]).toBe(skill);
|
||||
});
|
||||
|
||||
test('OpenCode skill-commands win custom commands and yield to discovered skills', () => {
|
||||
const command: Item = { name: 'deploy', source: 'opencode', description: 'Custom deploy' };
|
||||
const skillCommand: Item = {
|
||||
name: 'deploy',
|
||||
source: 'opencode',
|
||||
description: 'OpenCode skill command',
|
||||
isSkill: true,
|
||||
};
|
||||
const skill: Item = {
|
||||
name: 'deploy',
|
||||
source: 'skill',
|
||||
description: 'Discovered deploy skill',
|
||||
isSkill: true,
|
||||
};
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [command, skillCommand], [])).toEqual([{
|
||||
...skillCommand,
|
||||
searchAliases: ['Custom deploy'],
|
||||
}]);
|
||||
expect(mergeCommandAutocompleteItems([], [command, skillCommand], [skill])).toEqual([{
|
||||
...skill,
|
||||
searchAliases: ['OpenCode skill command', 'Custom deploy'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('keeps a case-distinct command when the built-in is disabled', () => {
|
||||
const builtIn: Item = { name: 'init', source: 'openchamber', isBuiltIn: true };
|
||||
const command: Item = { name: 'Init', source: 'opencode', description: 'Custom init' };
|
||||
const merged = mergeCommandAutocompleteItems([builtIn], [command], []);
|
||||
|
||||
expect(merged).toEqual([builtIn, command]);
|
||||
expect(merged.filter((item) => item.name !== 'init')).toEqual([command]);
|
||||
});
|
||||
|
||||
test('keeps first-seen ordering and unrelated commands', () => {
|
||||
const builtIns: Item[] = [{ name: 'undo', source: 'openchamber' }];
|
||||
const commands: Item[] = [
|
||||
{ name: 'test', source: 'opencode' },
|
||||
{ name: 'deploy', source: 'opencode' },
|
||||
];
|
||||
const skills: Item[] = [
|
||||
{ name: 'deploy', source: 'skill', isSkill: true },
|
||||
{ name: 'explain', source: 'skill', isSkill: true },
|
||||
];
|
||||
|
||||
const merged = mergeCommandAutocompleteItems(builtIns, commands, skills);
|
||||
|
||||
expect(merged.map((item) => item.name)).toEqual(['undo', 'test', 'deploy', 'explain']);
|
||||
expect(merged[2]).toBe(skills[0]);
|
||||
});
|
||||
|
||||
test('deduplicates repeated entries within each source without mutating inputs', () => {
|
||||
const first: Item = { name: 'test', source: 'opencode', description: 'First' };
|
||||
const duplicate: Item = { name: 'test', source: 'opencode', description: 'Second' };
|
||||
|
||||
expect(mergeCommandAutocompleteItems([], [first, duplicate], [])).toEqual([{
|
||||
...first,
|
||||
searchAliases: ['Second'],
|
||||
}]);
|
||||
expect(first.searchAliases).toBe(undefined);
|
||||
});
|
||||
|
||||
test('handles empty inputs', () => {
|
||||
expect(mergeCommandAutocompleteItems([], [], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildHighlightParts,
|
||||
isFenceClose,
|
||||
matchFenceOpen,
|
||||
mentionRangesToHighlightRanges,
|
||||
resolveHighlightSegments,
|
||||
tokenizeMarkdown,
|
||||
type HighlightRange,
|
||||
} from '../composerHighlight';
|
||||
|
||||
/**
|
||||
* Characterization tests: they record what the composer highlighter does TODAY,
|
||||
* before the CodeMirror migration replaces the textarea + mirror overlay. The
|
||||
* token grammar must survive that move unchanged, so these assert token spans
|
||||
* and styles rather than rendered classes.
|
||||
*/
|
||||
|
||||
/** Compact view of a range: the exact substring it covers, plus its style. */
|
||||
const spans = (text: string, ranges: HighlightRange[]) =>
|
||||
ranges.map((range) => [text.slice(range.start, range.end), range.style] as const);
|
||||
|
||||
const tokenize = (text: string) => spans(text, tokenizeMarkdown(text));
|
||||
|
||||
describe('tokenizeMarkdown — block constructs', () => {
|
||||
test('headings split into marker and content', () => {
|
||||
expect(tokenize('## Title')).toEqual([
|
||||
['##', 'marker'],
|
||||
['Title', 'heading'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('heading requires a space after the hashes', () => {
|
||||
expect(tokenize('##Title')).toEqual([]);
|
||||
});
|
||||
|
||||
test('seven hashes is not a heading', () => {
|
||||
expect(tokenize('####### too deep')).toEqual([]);
|
||||
});
|
||||
|
||||
test('blockquote marker is dimmed and content styled', () => {
|
||||
expect(tokenize('> quoted')).toEqual([
|
||||
['>', 'marker'],
|
||||
['quoted', 'blockquote'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('nested blockquote markers collapse into one marker range', () => {
|
||||
expect(tokenize('>> deep')).toEqual([
|
||||
['>>', 'marker'],
|
||||
['deep', 'blockquote'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('bullet and ordered list markers', () => {
|
||||
expect(tokenize('- one')).toEqual([['-', 'listMarker']]);
|
||||
expect(tokenize('* one')).toEqual([['*', 'listMarker']]);
|
||||
expect(tokenize('+ one')).toEqual([['+', 'listMarker']]);
|
||||
expect(tokenize('1. one')).toEqual([['1.', 'listMarker']]);
|
||||
expect(tokenize('2) two')).toEqual([['2)', 'listMarker']]);
|
||||
});
|
||||
|
||||
test('indented list markers keep their offset', () => {
|
||||
expect(tokenize(' - nested')).toEqual([['-', 'listMarker']]);
|
||||
});
|
||||
|
||||
test('a list marker needs trailing whitespace', () => {
|
||||
expect(tokenize('-nodash')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMarkdown — fenced code', () => {
|
||||
test('every line of a fence is codeFence, including the delimiters', () => {
|
||||
const text = '```ts\nconst a = 1;\n```';
|
||||
expect(tokenize(text)).toEqual([
|
||||
['```ts', 'codeFence'],
|
||||
['const a = 1;', 'codeFence'],
|
||||
['```', 'codeFence'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('tilde fences work the same way', () => {
|
||||
expect(tokenize('~~~\nbody\n~~~')).toEqual([
|
||||
['~~~', 'codeFence'],
|
||||
['body', 'codeFence'],
|
||||
['~~~', 'codeFence'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('markdown inside a fence is not tokenized', () => {
|
||||
expect(tokenize('```\n# not a heading\n- not a list\n```')).toEqual([
|
||||
['```', 'codeFence'],
|
||||
['# not a heading', 'codeFence'],
|
||||
['- not a list', 'codeFence'],
|
||||
['```', 'codeFence'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an unterminated fence swallows the rest of the text', () => {
|
||||
expect(tokenize('```\nstill open\n# nope')).toEqual([
|
||||
['```', 'codeFence'],
|
||||
['still open', 'codeFence'],
|
||||
['# nope', 'codeFence'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an info-string line does not close its own fence', () => {
|
||||
expect(isFenceClose('```js', '```')).toBe(false);
|
||||
expect(isFenceClose('```', '```')).toBe(true);
|
||||
expect(isFenceClose(' ``` ', '```')).toBe(true);
|
||||
});
|
||||
|
||||
test('a closing fence may be longer than the opening run', () => {
|
||||
expect(isFenceClose('````', '```')).toBe(true);
|
||||
expect(isFenceClose('``', '```')).toBe(false);
|
||||
});
|
||||
|
||||
test('matchFenceOpen reports marker and language', () => {
|
||||
expect(matchFenceOpen('```ts')).toEqual({ marker: '```', lang: 'ts' });
|
||||
expect(matchFenceOpen('~~~~')).toEqual({ marker: '~~~~', lang: '' });
|
||||
expect(matchFenceOpen('``')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMarkdown — inline spans', () => {
|
||||
test('inline code covers the backticks as well as the content', () => {
|
||||
expect(tokenize('run `bun test` now')).toEqual([['`bun test`', 'code']]);
|
||||
});
|
||||
|
||||
test('a double-backtick run is closed by a matching run', () => {
|
||||
expect(tokenize('``a ` b``')).toEqual([['``a ` b``', 'code']]);
|
||||
});
|
||||
|
||||
test('an unclosed backtick is left as plain text', () => {
|
||||
expect(tokenize('a ` b')).toEqual([]);
|
||||
});
|
||||
|
||||
test('links split into markers, text and url', () => {
|
||||
expect(tokenize('[docs](https://x.dev)')).toEqual([
|
||||
['[', 'marker'],
|
||||
['docs', 'link'],
|
||||
['](', 'marker'],
|
||||
['https://x.dev', 'linkUrl'],
|
||||
[')', 'marker'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an empty link text emits no link range', () => {
|
||||
expect(tokenize('[](url)')).toEqual([
|
||||
['[', 'marker'],
|
||||
['](', 'marker'],
|
||||
['url', 'linkUrl'],
|
||||
[')', 'marker'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline spans are scanned inside headings, quotes and list items', () => {
|
||||
expect(tokenize('# see `code`')).toEqual([
|
||||
['#', 'marker'],
|
||||
['see `code`', 'heading'],
|
||||
['`code`', 'code'],
|
||||
]);
|
||||
expect(tokenize('> see `code`')).toEqual([
|
||||
['>', 'marker'],
|
||||
['see `code`', 'blockquote'],
|
||||
['`code`', 'code'],
|
||||
]);
|
||||
expect(tokenize('- see `code`')).toEqual([
|
||||
['-', 'listMarker'],
|
||||
['`code`', 'code'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a bare ~path is not markdown — the language layer owns it', () => {
|
||||
expect(tokenize('~/repos/ocb/README.md')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMarkdown — emphasis', () => {
|
||||
test('double delimiters are strong, single are emphasis', () => {
|
||||
expect(tokenize('**bold**')).toEqual([
|
||||
['**', 'marker'],
|
||||
['bold', 'strong'],
|
||||
['**', 'marker'],
|
||||
]);
|
||||
expect(tokenize('*slanted*')).toEqual([
|
||||
['*', 'marker'],
|
||||
['slanted', 'emphasis'],
|
||||
['*', 'marker'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('triple delimiters are strong AND emphasis over the same span', () => {
|
||||
expect(tokenize('***both***')).toEqual([
|
||||
['***', 'marker'],
|
||||
['both', 'strong'],
|
||||
['both', 'emphasis'],
|
||||
['***', 'marker'],
|
||||
]);
|
||||
expect(tokenize('___both___').map(([, style]) => style))
|
||||
.toEqual(['marker', 'strong', 'emphasis', 'marker']);
|
||||
});
|
||||
|
||||
test('the underscore spellings work too', () => {
|
||||
expect(tokenize('_slanted_').map(([, style]) => style))
|
||||
.toEqual(['marker', 'emphasis', 'marker']);
|
||||
expect(tokenize('__bold__').map(([, style]) => style))
|
||||
.toEqual(['marker', 'strong', 'marker']);
|
||||
});
|
||||
|
||||
test('arithmetic is not emphasis', () => {
|
||||
expect(tokenize('2 * 3 * 4')).toEqual([]);
|
||||
expect(tokenize('a * b')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an identifier is not emphasis', () => {
|
||||
expect(tokenize('foo_bar_baz')).toEqual([]);
|
||||
expect(tokenize('SCREAMING_SNAKE_CASE')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an underscore span still works between words', () => {
|
||||
expect(tokenize('say _this_ loudly').map(([text]) => text))
|
||||
.toEqual(['_', 'this', '_']);
|
||||
});
|
||||
|
||||
test('a delimiter with nothing after it opens nothing', () => {
|
||||
expect(tokenize('trailing * ')).toEqual([]);
|
||||
expect(tokenize('ends with *')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an unclosed delimiter is left as plain text', () => {
|
||||
expect(tokenize('*never closed')).toEqual([]);
|
||||
});
|
||||
|
||||
test('emphasis does not span lines', () => {
|
||||
expect(tokenize('*open\nclose*')).toEqual([]);
|
||||
});
|
||||
|
||||
test('inline spans inside emphasis are still scanned', () => {
|
||||
expect(tokenize('**see `code`**').map(([text, style]) => `${text}:${style}`))
|
||||
.toContain('`code`:code');
|
||||
});
|
||||
|
||||
test('a list marker is not read as emphasis', () => {
|
||||
expect(tokenize('* item')).toEqual([['*', 'listMarker']]);
|
||||
});
|
||||
|
||||
test('emphasis inside a heading keeps both', () => {
|
||||
const text = '# A **strong** title';
|
||||
const styles = tokenize(text).map(([, style]) => style);
|
||||
expect(styles).toContain('heading');
|
||||
expect(styles).toContain('strong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMarkdown — attention', () => {
|
||||
test('a !!! line marks its content', () => {
|
||||
expect(tokenize('!!! important')).toEqual([
|
||||
['!!!', 'marker'],
|
||||
['important', 'attention'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('it needs a space after the marks', () => {
|
||||
expect(tokenize('!!!important')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an emphatic sentence is not an attention line', () => {
|
||||
expect(tokenize('that is wild!!!')).toEqual([]);
|
||||
expect(tokenize('!! close')).toEqual([]);
|
||||
});
|
||||
|
||||
test('inline spans inside an attention line are scanned', () => {
|
||||
expect(tokenize('!!! check `this`').map(([, style]) => style))
|
||||
.toContain('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMarkdown — offsets across lines', () => {
|
||||
test('ranges are absolute offsets into the whole text', () => {
|
||||
const text = 'intro\n# Head\n- item';
|
||||
const ranges = tokenizeMarkdown(text);
|
||||
for (const range of ranges) {
|
||||
expect(text.slice(range.start, range.end).length).toBe(range.end - range.start);
|
||||
}
|
||||
expect(spans(text, ranges)).toEqual([
|
||||
['#', 'marker'],
|
||||
['Head', 'heading'],
|
||||
['-', 'listMarker'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('empty input yields no ranges', () => {
|
||||
expect(tokenizeMarkdown('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionRangesToHighlightRanges', () => {
|
||||
test('file and agent mentions map to their own styles', () => {
|
||||
expect(mentionRangesToHighlightRanges([
|
||||
{ start: 0, end: 5, kind: 'file' },
|
||||
{ start: 6, end: 9, kind: 'agent' },
|
||||
])).toEqual([
|
||||
{ start: 0, end: 5, style: 'mentionFile' },
|
||||
{ start: 6, end: 9, style: 'mentionAgent' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHighlightSegments', () => {
|
||||
test('segments tile the whole text without gaps or overlap', () => {
|
||||
const text = '# Title\nsee `code` and more';
|
||||
const segments = resolveHighlightSegments(text, tokenizeMarkdown(text));
|
||||
expect(segments[0].start).toBe(0);
|
||||
expect(segments[segments.length - 1].end).toBe(text.length);
|
||||
for (let i = 1; i < segments.length; i += 1) {
|
||||
expect(segments[i].start).toBe(segments[i - 1].end);
|
||||
}
|
||||
});
|
||||
|
||||
test('no text and no ranges resolve to nothing', () => {
|
||||
expect(resolveHighlightSegments('', [{ start: 0, end: 1, style: 'code' }])).toEqual([]);
|
||||
expect(resolveHighlightSegments('abc', [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('adjacent same-class stretches are merged into one segment', () => {
|
||||
const segments = resolveHighlightSegments('abcdef', [
|
||||
{ start: 0, end: 3, style: 'code' },
|
||||
{ start: 3, end: 6, style: 'code' },
|
||||
]);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0].start).toBe(0);
|
||||
expect(segments[0].end).toBe(6);
|
||||
});
|
||||
|
||||
test('segments agree with the parts the overlay renders', () => {
|
||||
const text = 'a `b` c';
|
||||
const ranges = tokenizeMarkdown(text);
|
||||
const segments = resolveHighlightSegments(text, ranges);
|
||||
const parts = buildHighlightParts(text, ranges);
|
||||
expect(parts!.map((part) => part.text))
|
||||
.toEqual(segments.map((segment) => text.slice(segment.start, segment.end)));
|
||||
expect(parts!.map((part) => part.className))
|
||||
.toEqual(segments.map((segment) => segment.className));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHighlightSegments — additive styles', () => {
|
||||
/**
|
||||
* A segment carries one class string, so weight and colour cannot be
|
||||
* chosen between: emphasis composes onto whatever construct it sits in.
|
||||
*/
|
||||
test('emphasis inside a heading keeps the heading colour and gains weight', () => {
|
||||
const text = '# A **strong** title';
|
||||
const segment = resolveHighlightSegments(text, tokenizeMarkdown(text))
|
||||
.find((candidate) => text.slice(candidate.start, candidate.end) === 'strong');
|
||||
expect(segment!.className.includes('font-semibold')).toBe(true);
|
||||
expect(segment!.className.includes('--syntax-keyword')).toBe(true);
|
||||
});
|
||||
|
||||
test('emphasis on its own still renders over the default text colour', () => {
|
||||
const text = '*slanted*';
|
||||
const segment = resolveHighlightSegments(text, tokenizeMarkdown(text))
|
||||
.find((candidate) => text.slice(candidate.start, candidate.end) === 'slanted');
|
||||
expect(segment!.className.includes('italic')).toBe(true);
|
||||
});
|
||||
|
||||
test('a style is not repeated when two identical ranges overlap', () => {
|
||||
const parts = resolveHighlightSegments('abcd', [
|
||||
{ start: 0, end: 4, style: 'strong' },
|
||||
{ start: 0, end: 4, style: 'strong' },
|
||||
]);
|
||||
expect(parts[0].className.split('font-semibold').length - 1).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHighlightParts', () => {
|
||||
test('returns null when there is nothing to highlight', () => {
|
||||
expect(buildHighlightParts('', [])).toBeNull();
|
||||
expect(buildHighlightParts('plain text', [])).toBeNull();
|
||||
});
|
||||
|
||||
test('covers the full text and preserves it exactly', () => {
|
||||
const text = '# Title\nplain `code` tail';
|
||||
const parts = buildHighlightParts(text, tokenizeMarkdown(text));
|
||||
expect(parts).not.toBeNull();
|
||||
expect(parts!.map((part) => part.text).join('')).toBe(text);
|
||||
});
|
||||
|
||||
test('adjacent parts sharing a class are coalesced', () => {
|
||||
const parts = buildHighlightParts('abcdef', [
|
||||
{ start: 0, end: 3, style: 'code' },
|
||||
{ start: 3, end: 6, style: 'code' },
|
||||
]);
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts![0].text).toBe('abcdef');
|
||||
});
|
||||
|
||||
test('higher priority wins on overlap — a mention beats inline code', () => {
|
||||
const text = '`@a/b.ts`';
|
||||
const parts = buildHighlightParts(text, [
|
||||
{ start: 0, end: text.length, style: 'code' },
|
||||
{ start: 1, end: text.length - 1, style: 'mentionFile' },
|
||||
]);
|
||||
expect(parts!.map((part) => part.text)).toEqual(['`', '@a/b.ts', '`']);
|
||||
expect(parts![1].className).toBe(parts![1].className);
|
||||
expect(parts![0].className).not.toBe(parts![1].className);
|
||||
});
|
||||
|
||||
test('equal priority resolves to the earliest range in input order', () => {
|
||||
const parts = buildHighlightParts('abcd', [
|
||||
{ start: 0, end: 4, style: 'mentionFile' },
|
||||
{ start: 0, end: 4, style: 'mentionAgent' },
|
||||
]);
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts![0].className).toBe(
|
||||
buildHighlightParts('abcd', [{ start: 0, end: 4, style: 'mentionFile' }])![0].className,
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit priority overrides the style table', () => {
|
||||
const parts = buildHighlightParts('abcd', [
|
||||
{ start: 0, end: 4, style: 'mentionFile' },
|
||||
{ start: 0, end: 4, style: 'marker', priority: 999 },
|
||||
]);
|
||||
expect(parts![0].className).toBe(
|
||||
buildHighlightParts('abcd', [{ start: 0, end: 4, style: 'marker' }])![0].className,
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit className overrides the style class', () => {
|
||||
const parts = buildHighlightParts('abcd', [
|
||||
{ start: 0, end: 4, style: 'code', className: 'custom-class' },
|
||||
]);
|
||||
expect(parts).toEqual([{ text: 'abcd', className: 'custom-class' }]);
|
||||
});
|
||||
|
||||
test('zero-width ranges are ignored', () => {
|
||||
const parts = buildHighlightParts('abcd', [{ start: 2, end: 2, style: 'code' }]);
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts![0].text).toBe('abcd');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Mirrors the ChatContainer chat-settings-sync guard.
|
||||
* VS Code/Cursor/Positron webviews delete `window.parent`, so the old
|
||||
* `window.parent === window` check still fell through to `.postMessage` and
|
||||
* crashed chat open with:
|
||||
* TypeError: Cannot read properties of undefined (reading 'postMessage')
|
||||
*/
|
||||
const canPostMessageToParentFrame = (win: { parent?: unknown } | undefined): boolean => {
|
||||
if (typeof win === 'undefined' || !win) return false;
|
||||
return Boolean(win.parent) && win.parent !== win;
|
||||
};
|
||||
|
||||
describe('parent-frame postMessage guard (VS Code webview)', () => {
|
||||
test('rejects when parent was deleted (VS Code webview injector behavior)', () => {
|
||||
const vscodeLikeWindow = { parent: undefined };
|
||||
expect(canPostMessageToParentFrame(vscodeLikeWindow)).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects when parent is null', () => {
|
||||
expect(canPostMessageToParentFrame({ parent: null })).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects top-level windows where parent === self', () => {
|
||||
const topLevel = {} as { parent?: unknown };
|
||||
topLevel.parent = topLevel;
|
||||
expect(canPostMessageToParentFrame(topLevel)).toBe(false);
|
||||
});
|
||||
|
||||
test('allows real embedded iframe parent windows', () => {
|
||||
const parent = {};
|
||||
const child = { parent };
|
||||
expect(canPostMessageToParentFrame(child)).toBe(true);
|
||||
});
|
||||
|
||||
test('old guard incorrectly allows deleted parent', () => {
|
||||
const vscodeLikeWindow = { parent: undefined as unknown };
|
||||
const oldGuardWouldSkip = vscodeLikeWindow.parent === vscodeLikeWindow;
|
||||
expect(oldGuardWouldSkip).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
|
||||
const session = (parentID?: string): Session => ({
|
||||
id: 'session',
|
||||
slug: 'session',
|
||||
title: 'Session',
|
||||
version: '1',
|
||||
projectID: 'project',
|
||||
directory: '/repo',
|
||||
parentID,
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('resolveChatPromptReadOnly', () => {
|
||||
test('allows prompting a subagent without requiring its parent record', () => {
|
||||
expect(resolveChatPromptReadOnly(session('parent'), true, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps a subagent read-only when prompting is disabled', () => {
|
||||
expect(resolveChatPromptReadOnly(session('parent'), false, false)).toBe(true);
|
||||
});
|
||||
|
||||
test('preserves the surface read-only state for root sessions', () => {
|
||||
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
|
||||
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const resolveChatPromptReadOnly = (
|
||||
session: Session | null | undefined,
|
||||
allowPromptingSubagentSessions: boolean,
|
||||
readOnly: boolean,
|
||||
): boolean => {
|
||||
if (session?.parentID) {
|
||||
return !allowPromptingSubagentSessions;
|
||||
}
|
||||
|
||||
return readOnly;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fuzzyMatch } from '@/lib/utils';
|
||||
|
||||
export interface CommandAutocompleteSearchItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
isBuiltIn?: boolean;
|
||||
isSkill?: boolean;
|
||||
}
|
||||
|
||||
function addSearchAliases<T extends CommandAutocompleteSearchItem>(winner: T, duplicate: T): T {
|
||||
const existingAliases = winner.searchAliases ?? [];
|
||||
const aliases = [
|
||||
...existingAliases,
|
||||
...(winner.name === duplicate.name ? [] : [duplicate.name]),
|
||||
...(duplicate.description ? [duplicate.description] : []),
|
||||
...(duplicate.searchAliases ?? []),
|
||||
].filter((alias, index, values) => alias !== winner.description && values.indexOf(alias) === index);
|
||||
const unchanged = aliases.length === existingAliases.length
|
||||
&& aliases.every((alias, index) => alias === existingAliases[index]);
|
||||
|
||||
return unchanged ? winner : { ...winner, searchAliases: aliases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence is local command, discovered skill, OpenCode skill-command, then
|
||||
* custom/plugin command. Identity matches session.command's case-sensitive lookup.
|
||||
*/
|
||||
export function mergeCommandAutocompleteItems<T extends CommandAutocompleteSearchItem>(
|
||||
builtIns: T[],
|
||||
commands: T[],
|
||||
skills: T[],
|
||||
): T[] {
|
||||
const merged: T[] = [];
|
||||
const byName = new Map<string, { index: number; item: T; precedence: number }>();
|
||||
|
||||
const addItems = (items: T[], getPrecedence: (item: T) => number) => {
|
||||
for (const item of items) {
|
||||
const precedence = getPrecedence(item);
|
||||
const identity = item.name;
|
||||
const existing = byName.get(identity);
|
||||
if (!existing) {
|
||||
byName.set(identity, { index: merged.length, item, precedence });
|
||||
merged.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const winner = precedence > existing.precedence
|
||||
? addSearchAliases(item, existing.item)
|
||||
: addSearchAliases(existing.item, item);
|
||||
merged[existing.index] = winner;
|
||||
byName.set(identity, {
|
||||
index: existing.index,
|
||||
item: winner,
|
||||
precedence: Math.max(existing.precedence, precedence),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addItems(builtIns, () => 3);
|
||||
addItems(commands, (item) => item.isBuiltIn ? 3 : item.isSkill ? 1 : 0);
|
||||
addItems(skills, () => 2);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function commandMatchesSearch(command: CommandAutocompleteSearchItem, query: string): boolean {
|
||||
return fuzzyMatch(command.name, query)
|
||||
|| Boolean(command.description && fuzzyMatch(command.description, query))
|
||||
|| Boolean(command.searchAliases?.some((alias) => fuzzyMatch(alias, query)));
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# Composer
|
||||
|
||||
The chat composer: the prompt language, the editor that renders it, and
|
||||
everything between typing and sending.
|
||||
|
||||
`ChatInput.tsx` (one directory up) is the orchestrator. It holds the composer's
|
||||
own state and wires these modules together; it should not grow logic that
|
||||
belongs to one of them.
|
||||
|
||||
## Layers
|
||||
|
||||
| Directory | Owns |
|
||||
|---|---|
|
||||
| `language/` | What the text *means*: `@` references, `/` and `#` tokens, markdown, and which picker a caret asks for |
|
||||
| `editor/` | The CodeMirror view that renders the language and owns the caret |
|
||||
| `state/` | Composer state with a lifecycle: drafts, mobile shell, history, popup placement, draft targeting |
|
||||
| `submit/` | Turning what the user has into what gets sent |
|
||||
| `attachments/` | Files: paths, drop payloads |
|
||||
| `ui/` | Presentation |
|
||||
| `text.ts` | How inserted text meets the text already there |
|
||||
|
||||
## The prompt language
|
||||
|
||||
`language/` is the single source of truth for composer syntax. Everything that
|
||||
needs to know what a token means — highlighting, send-time resolution, and the
|
||||
autocomplete triggers — goes through it.
|
||||
|
||||
**This is the invariant that matters most in this module.** Before it existed,
|
||||
the `@` rule was written four times with divergent cleanup and the `/` rule
|
||||
three times with different valid character sets, so a token could be painted as
|
||||
a reference and then not resolve as one. Adding a construct meant finding every
|
||||
copy.
|
||||
|
||||
- `mentions.ts` — `@` references. The `start..end` span is the reference
|
||||
itself and is what gets highlighted; in `see @a/b.ts,` the comma is sentence
|
||||
punctuation, not part of the file being referenced. Mentions are plain
|
||||
editable text: deleting a character edits the token and reopens the mention
|
||||
picker, the same way `/skill` tokens behave — not an atomic delete.
|
||||
- `prefixTokens.ts` — `/command`, `/skill`, `#snippet`. Scanning is deliberately
|
||||
generous; **membership in the command, skill or snippet registry is the
|
||||
authority**, not the pattern. An unknown `/token` stays plain prose.
|
||||
- `triggers.ts` — which picker a caret position asks for. Exactly one can be
|
||||
active, with precedence `command > skill > snippet > mention`.
|
||||
- `tokenize.ts` — one pass producing every highlight range. Adding a construct
|
||||
to the language means adding it here, once.
|
||||
|
||||
## The editor
|
||||
|
||||
`editor/` wraps CodeMirror. The document is a plain string: `getValue()` is
|
||||
exactly what gets sent, so nothing downstream serializes a rich document model
|
||||
back into a prompt.
|
||||
|
||||
The composer previously painted a transparent `<textarea>` over a mirror
|
||||
`<div>`. That restricted highlighting to styles which do not change glyph
|
||||
advance width — colour, background, underline — because anything else made the
|
||||
mirror drift out from under the caret. Bold and italic were impossible, and the
|
||||
overlay was disabled outright on mobile, where wrapped text drifted anyway.
|
||||
**Those constraints are gone**; adding a width-affecting style is now a
|
||||
question of design, not of feasibility.
|
||||
|
||||
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
|
||||
keeps typing on the drawn-selection code path, and removing it makes
|
||||
CodeMirror enforce cursor association on the native selection, which iOS
|
||||
answers with severe input lag. Every device also layers
|
||||
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
|
||||
the native selection, and — only while a range is selected — the native caret,
|
||||
hiding the painted layers those replace. The native selection is the one that
|
||||
shows for two reasons: the painted layer sits behind the content, so tokens
|
||||
with their own background (inline code, fences) cover it completely; and
|
||||
iOS's selection drag handles attach to the visible native selection and take
|
||||
their colour from the caret, so a transparent caret means invisible handles.
|
||||
The range-only caret scoping is load-bearing — a native caret visible while
|
||||
typing makes WebKit re-render its caret UI after every keystroke, felt as
|
||||
severe input lag. The selection tint comes from `--primary`, not the selection
|
||||
token:
|
||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||
mix of it is nearly invisible.
|
||||
|
||||
`composerLanguage.ts` retokenizes the whole document on every change. The
|
||||
composer holds a prompt, not a source file: it is short enough that a full pass
|
||||
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
and the send path reading the same grammar.
|
||||
|
||||
## Ordering rules worth knowing
|
||||
|
||||
- `editor/ComposerEditor.tsx` forwards a click on the composer's padding by
|
||||
focusing the view *before* setting the selection: CodeMirror reveals its
|
||||
drawn caret through a class it only writes while applying an update, so the
|
||||
selection has to be the update that follows the focus.
|
||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||
oldest queued message becomes primary; **inline comments attach to the last
|
||||
body the user authored** rather than becoming their own part; PR instructions
|
||||
precede the PR diff.
|
||||
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||
session) identity. Writes are debounced while typing but forced at every edge
|
||||
where the page may stop running, because a pending timer is not a saved
|
||||
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.
|
||||
- `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.
|
||||
|
||||
## Mobile
|
||||
|
||||
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
|
||||
mostly not state machines but corrections for specific platform behaviors:
|
||||
mobile browsers dismissing the keyboard before a tap's click lands, iOS
|
||||
refusing programmatic focus outside a gesture, WebKit leaving the layout
|
||||
viewport panned after the keyboard hides, overlay chains handing off through a
|
||||
frame where nothing is open.
|
||||
|
||||
**Every timeout and `flushSync` in them has a reason recorded next to it, and
|
||||
none of them is verifiable outside a real device.** Change them only against
|
||||
hardware.
|
||||
|
||||
## Testing
|
||||
|
||||
The package has no DOM test environment, so coverage stops at the state and
|
||||
logic layers: the language, the submit assembly, path and drop handling, text
|
||||
splicing, message history, and the CodeMirror language extension at the
|
||||
`EditorState` level.
|
||||
|
||||
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
|
||||
tests** and are verified by hand. Do not report a change to them as validated
|
||||
on the strength of type-check and unit tests.
|
||||
|
||||
Run tests per file (`bun test <path>`): `mock.module` is process-global, so
|
||||
suites that install module mocks are order-dependent.
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from '../text';
|
||||
|
||||
describe('appendWithLineBreaks', () => {
|
||||
test('separates the block with a blank line and ends with one', () => {
|
||||
expect(appendWithLineBreaks('intro', 'body')).toBe('intro\n\nbody\n\n');
|
||||
});
|
||||
|
||||
test('does not stack separators that are already there', () => {
|
||||
expect(appendWithLineBreaks('intro\n\n', 'body')).toBe('intro\n\nbody\n\n');
|
||||
expect(appendWithLineBreaks('intro\n', 'body')).toBe('intro\n\nbody\n\n');
|
||||
});
|
||||
|
||||
test('an empty base needs no separator', () => {
|
||||
expect(appendWithLineBreaks('', 'body')).toBe('body\n\n');
|
||||
});
|
||||
|
||||
test('trailing breaks in the inserted block are normalized, not doubled', () => {
|
||||
expect(appendWithLineBreaks('a', 'b\n')).toBe('a\n\nb\n\n');
|
||||
expect(appendWithLineBreaks('a', 'b\n\n')).toBe('a\n\nb\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendInlineText', () => {
|
||||
test('joins with a single space and leaves the caret room', () => {
|
||||
expect(appendInlineText('hello', 'world')).toBe('hello world ');
|
||||
});
|
||||
|
||||
test('does not double an existing space', () => {
|
||||
expect(appendInlineText('hello ', 'world')).toBe('hello world ');
|
||||
expect(appendInlineText('hello\n', 'world')).toBe('hello\nworld ');
|
||||
});
|
||||
|
||||
test('an empty base yields just the text', () => {
|
||||
expect(appendInlineText('', 'world')).toBe('world ');
|
||||
});
|
||||
|
||||
test('blank additions are ignored', () => {
|
||||
expect(appendInlineText('hello', ' ')).toBe('hello');
|
||||
expect(appendInlineText('hello', '')).toBe('hello');
|
||||
});
|
||||
|
||||
test('the addition is trimmed before joining', () => {
|
||||
expect(appendInlineText('hello', ' world ')).toBe('hello world ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withInlineInsertionBoundaries', () => {
|
||||
test('pads between two words', () => {
|
||||
expect(withInlineInsertionBoundaries('mid', 'left', 'right')).toBe(' mid ');
|
||||
});
|
||||
|
||||
test('adds nothing at the very start or end', () => {
|
||||
expect(withInlineInsertionBoundaries('mid', '', '')).toBe('mid');
|
||||
});
|
||||
|
||||
test('respects whitespace already present', () => {
|
||||
expect(withInlineInsertionBoundaries('mid', 'left ', ' right')).toBe('mid');
|
||||
});
|
||||
|
||||
test('no space after an opening bracket', () => {
|
||||
expect(withInlineInsertionBoundaries('mid', '(', 'x')).toBe('mid ');
|
||||
expect(withInlineInsertionBoundaries('mid', '[', 'x')).toBe('mid ');
|
||||
});
|
||||
|
||||
test('no space before a closing bracket or sentence punctuation', () => {
|
||||
expect(withInlineInsertionBoundaries('mid', 'x', ')')).toBe(' mid');
|
||||
expect(withInlineInsertionBoundaries('mid', 'x', '.')).toBe(' mid');
|
||||
expect(withInlineInsertionBoundaries('mid', 'x', ', rest')).toBe(' mid');
|
||||
});
|
||||
|
||||
test('empty content stays empty', () => {
|
||||
expect(withInlineInsertionBoundaries('', 'left', 'right')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildImagePasteInsertion', () => {
|
||||
test('a citation pasted alone is the whole insertion', () => {
|
||||
expect(buildImagePasteInsertion('', '[shot.png]')).toBe('[shot.png]');
|
||||
});
|
||||
|
||||
test('text pasted with the image keeps the citation after it', () => {
|
||||
expect(buildImagePasteInsertion('look', '[shot.png]')).toBe('look [shot.png]');
|
||||
});
|
||||
|
||||
test('an existing trailing space is not doubled', () => {
|
||||
expect(buildImagePasteInsertion('look ', '[shot.png]')).toBe('look [shot.png]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldWrapSelectionAsLink', () => {
|
||||
test('a URL pasted over selected text becomes a link', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', 'docs')).toBe(true);
|
||||
expect(shouldWrapSelectionAsLink('mailto:a@b.c', 'mail me')).toBe(true);
|
||||
});
|
||||
|
||||
test('non-URLs are pasted normally', () => {
|
||||
expect(shouldWrapSelectionAsLink('just text', 'docs')).toBe(false);
|
||||
expect(shouldWrapSelectionAsLink('ftp://x.dev', 'docs')).toBe(false);
|
||||
});
|
||||
|
||||
test('a URL containing whitespace is not one', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev y', 'docs')).toBe(false);
|
||||
});
|
||||
|
||||
test('an empty or blank selection has nothing to wrap', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', '')).toBe(false);
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', ' ')).toBe(false);
|
||||
});
|
||||
|
||||
test('a selection that is already a link is not nested', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
collectDroppedFileUris,
|
||||
collectDroppedFiles,
|
||||
hasDraggedFiles,
|
||||
INTERNAL_FILE_PATH_TYPE,
|
||||
} from '../dataTransfer';
|
||||
|
||||
/** A minimal DataTransfer stand-in; only what the helpers read is modelled. */
|
||||
function fakeTransfer(options: {
|
||||
types?: string[];
|
||||
data?: Record<string, string>;
|
||||
files?: File[];
|
||||
items?: Array<{ kind: string; file?: File }>;
|
||||
throwOnGetData?: boolean;
|
||||
}): DataTransfer {
|
||||
const data = options.data ?? {};
|
||||
return {
|
||||
types: options.types ?? Object.keys(data),
|
||||
files: options.files ?? [],
|
||||
items: (options.items ?? []).map((item) => ({
|
||||
kind: item.kind,
|
||||
getAsFile: () => item.file ?? null,
|
||||
})),
|
||||
getData: (type: string) => {
|
||||
if (options.throwOnGetData) throw new Error('unavailable during dragover');
|
||||
return data[type] ?? '';
|
||||
},
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
const file = (name: string) => new File(['x'], name, { type: 'text/plain' });
|
||||
|
||||
describe('hasDraggedFiles', () => {
|
||||
test('real files are recognized', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({ files: [file('a.txt')] }))).toBe(true);
|
||||
});
|
||||
|
||||
test('a declared file-bearing type is enough', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: ['Files'] }))).toBe(true);
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: ['text/uri-list'] }))).toBe(true);
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: ['CodeFiles'] }))).toBe(true);
|
||||
});
|
||||
|
||||
test('an internal file-tree drag is recognized', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: [INTERNAL_FILE_PATH_TYPE] }))).toBe(true);
|
||||
});
|
||||
|
||||
test('a VS Code tree type is recognized by prefix', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: ['application/vnd.code.tree.explorer'] })))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('type matching is case-insensitive', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({ types: ['FILES'] }))).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back to scanning payloads when the types say nothing useful', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({
|
||||
types: ['application/unknown'],
|
||||
data: { 'text/plain': '/repo/a.ts' },
|
||||
}))).toBe(true);
|
||||
});
|
||||
|
||||
test('dragged text is not a file drag', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({
|
||||
types: ['text/plain'],
|
||||
data: { 'text/plain': 'just some words' },
|
||||
}))).toBe(false);
|
||||
});
|
||||
|
||||
test('a missing transfer is not a file drag', () => {
|
||||
expect(hasDraggedFiles(null)).toBe(false);
|
||||
expect(hasDraggedFiles(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('an unreadable payload does not abort the scan', () => {
|
||||
expect(hasDraggedFiles(fakeTransfer({
|
||||
types: ['application/unknown'],
|
||||
throwOnGetData: true,
|
||||
}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectDroppedFiles', () => {
|
||||
test('reads the files list', () => {
|
||||
expect(collectDroppedFiles(fakeTransfer({ files: [file('a.txt')] })).map((f) => f.name))
|
||||
.toEqual(['a.txt']);
|
||||
});
|
||||
|
||||
test('falls back to the item list', () => {
|
||||
expect(collectDroppedFiles(fakeTransfer({
|
||||
items: [{ kind: 'file', file: file('b.txt') }],
|
||||
})).map((f) => f.name)).toEqual(['b.txt']);
|
||||
});
|
||||
|
||||
test('non-file items are skipped', () => {
|
||||
expect(collectDroppedFiles(fakeTransfer({
|
||||
items: [{ kind: 'string' }, { kind: 'file', file: file('c.txt') }],
|
||||
})).map((f) => f.name)).toEqual(['c.txt']);
|
||||
});
|
||||
|
||||
test('a file item that yields nothing is skipped', () => {
|
||||
expect(collectDroppedFiles(fakeTransfer({ items: [{ kind: 'file' }] }))).toEqual([]);
|
||||
});
|
||||
|
||||
test('an empty or missing transfer yields nothing', () => {
|
||||
expect(collectDroppedFiles(fakeTransfer({}))).toEqual([]);
|
||||
expect(collectDroppedFiles(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectDroppedFileUris', () => {
|
||||
test('reads paths out of a VS Code payload', () => {
|
||||
expect(collectDroppedFileUris(fakeTransfer({
|
||||
data: { 'text/uri-list': 'file:///repo/a.ts' },
|
||||
}))).toEqual(['file:///repo/a.ts']);
|
||||
});
|
||||
|
||||
test('the same path across several types is returned once', () => {
|
||||
expect(collectDroppedFileUris(fakeTransfer({
|
||||
data: { 'text/uri-list': '/repo/a.ts', 'text/plain': '/repo/a.ts' },
|
||||
}))).toEqual(['/repo/a.ts']);
|
||||
});
|
||||
|
||||
test('a payload with no paths yields nothing', () => {
|
||||
expect(collectDroppedFileUris(fakeTransfer({
|
||||
data: { 'text/plain': 'words' },
|
||||
}))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a transfer without getData yields nothing', () => {
|
||||
expect(collectDroppedFileUris({} as DataTransfer)).toEqual([]);
|
||||
expect(collectDroppedFileUris(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
encodeFilePath,
|
||||
isLikelyAbsolutePath,
|
||||
normalizeDroppedPath,
|
||||
normalizePath,
|
||||
parseDroppedFileReferences,
|
||||
toLikelyFileDropReference,
|
||||
toProjectRelativeMentionPath,
|
||||
toServerFileUrl,
|
||||
} from '../filePaths';
|
||||
|
||||
describe('encodeFilePath', () => {
|
||||
test('encodes segments but keeps separators', () => {
|
||||
expect(encodeFilePath('/a/b c/d.txt')).toBe('/a/b%20c/d.txt');
|
||||
});
|
||||
|
||||
test('backslashes become forward slashes', () => {
|
||||
expect(encodeFilePath('a\\b\\c.txt')).toBe('a/b/c.txt');
|
||||
});
|
||||
|
||||
test('a Windows drive letter is preserved unencoded', () => {
|
||||
expect(encodeFilePath('C:\\Users\\me\\a b.txt')).toBe('/C:/Users/me/a%20b.txt');
|
||||
});
|
||||
|
||||
test('special characters in a name are encoded', () => {
|
||||
expect(encodeFilePath('/a/b#c?d.txt')).toBe('/a/b%23c%3Fd.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toServerFileUrl', () => {
|
||||
test('wraps a plain path', () => {
|
||||
expect(toServerFileUrl('/repo/a.ts')).toBe('file:///repo/a.ts');
|
||||
});
|
||||
|
||||
test('an existing file URL is passed through unchanged', () => {
|
||||
expect(toServerFileUrl('file:///repo/a.ts')).toBe('file:///repo/a.ts');
|
||||
expect(toServerFileUrl('FILE:///repo/a.ts')).toBe('FILE:///repo/a.ts');
|
||||
});
|
||||
|
||||
test('a Windows path becomes a valid file URL', () => {
|
||||
expect(toServerFileUrl('C:\\repo\\a.ts')).toBe('file:///C:/repo/a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLikelyAbsolutePath', () => {
|
||||
test('recognizes posix, UNC and Windows roots', () => {
|
||||
expect(isLikelyAbsolutePath('/repo/a.ts')).toBe(true);
|
||||
expect(isLikelyAbsolutePath('\\\\share\\a.ts')).toBe(true);
|
||||
expect(isLikelyAbsolutePath('C:/repo')).toBe(true);
|
||||
expect(isLikelyAbsolutePath('C:\\repo')).toBe(true);
|
||||
});
|
||||
|
||||
test('relative paths are not absolute', () => {
|
||||
expect(isLikelyAbsolutePath('src/a.ts')).toBe(false);
|
||||
expect(isLikelyAbsolutePath('./a.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLikelyFileDropReference', () => {
|
||||
test('accepts an absolute path and a file URL', () => {
|
||||
expect(toLikelyFileDropReference('/repo/a.ts')).toBe('/repo/a.ts');
|
||||
expect(toLikelyFileDropReference('file:///repo/a.ts')).toBe('file:///repo/a.ts');
|
||||
});
|
||||
|
||||
test('strips surrounding quotes and whitespace', () => {
|
||||
expect(toLikelyFileDropReference(' "/repo/a.ts" ')).toBe('/repo/a.ts');
|
||||
});
|
||||
|
||||
test('rejects relative paths, prose and empty input', () => {
|
||||
expect(toLikelyFileDropReference('src/a.ts')).toBeNull();
|
||||
expect(toLikelyFileDropReference('some dropped sentence')).toBeNull();
|
||||
expect(toLikelyFileDropReference(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a multi-line value, which is a document not a path', () => {
|
||||
expect(toLikelyFileDropReference('/repo/a.ts\n/repo/b.ts')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDroppedFileReferences', () => {
|
||||
test('reads a single path', () => {
|
||||
expect(parseDroppedFileReferences('/repo/a.ts')).toEqual(['/repo/a.ts']);
|
||||
});
|
||||
|
||||
test('reads a newline-separated URI list', () => {
|
||||
expect(parseDroppedFileReferences('file:///repo/a.ts\nfile:///repo/b.ts'))
|
||||
.toEqual(['file:///repo/a.ts', 'file:///repo/b.ts']);
|
||||
});
|
||||
|
||||
test('finds paths nested inside a JSON payload', () => {
|
||||
const payload = JSON.stringify({ items: [{ resource: { path: '/repo/a.ts' } }] });
|
||||
expect(parseDroppedFileReferences(payload)).toEqual(['/repo/a.ts']);
|
||||
});
|
||||
|
||||
test('duplicates across passes are collapsed', () => {
|
||||
expect(parseDroppedFileReferences('/repo/a.ts\n/repo/a.ts')).toEqual(['/repo/a.ts']);
|
||||
});
|
||||
|
||||
test('a payload with no paths yields nothing', () => {
|
||||
expect(parseDroppedFileReferences('just some text')).toEqual([]);
|
||||
expect(parseDroppedFileReferences('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('deeply buried paths beyond the depth bound are not searched forever', () => {
|
||||
let nested: unknown = '/repo/deep.ts';
|
||||
for (let i = 0; i < 20; i += 1) nested = { nested };
|
||||
expect(parseDroppedFileReferences(JSON.stringify(nested))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDroppedPath', () => {
|
||||
test('a plain path is returned as-is', () => {
|
||||
expect(normalizeDroppedPath('/repo/a.ts')).toBe('/repo/a.ts');
|
||||
});
|
||||
|
||||
test('a file URL is decoded back to a path', () => {
|
||||
expect(normalizeDroppedPath('file:///repo/a%20b.ts')).toBe('/repo/a b.ts');
|
||||
});
|
||||
|
||||
test('a Windows file URL drops the slash before the drive letter', () => {
|
||||
expect(normalizeDroppedPath('file:///C:/repo/a.ts')).toBe('C:/repo/a.ts');
|
||||
});
|
||||
|
||||
test('a malformed file URL still yields something usable', () => {
|
||||
expect(normalizeDroppedPath('file://%%%')).toBe('%%%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('trims and drops a trailing separator', () => {
|
||||
expect(normalizePath(' /repo/dir/ ')).toBe('/repo/dir');
|
||||
expect(normalizePath('/repo/dir///')).toBe('/repo/dir');
|
||||
});
|
||||
|
||||
test('backslashes become forward slashes', () => {
|
||||
expect(normalizePath('C:\\repo\\dir')).toBe('C:/repo/dir');
|
||||
});
|
||||
|
||||
test('the root keeps its slash', () => {
|
||||
expect(normalizePath('/')).toBe('/');
|
||||
});
|
||||
|
||||
test('blank and non-string input yield null', () => {
|
||||
expect(normalizePath('')).toBeNull();
|
||||
expect(normalizePath(' ')).toBeNull();
|
||||
expect(normalizePath(null)).toBeNull();
|
||||
expect(normalizePath(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toProjectRelativeMentionPath', () => {
|
||||
test('strips the project root', () => {
|
||||
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '/repo')).toBe('src/a.ts');
|
||||
});
|
||||
|
||||
test('tolerates a trailing slash on the root', () => {
|
||||
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '/repo/')).toBe('src/a.ts');
|
||||
});
|
||||
|
||||
test('a path outside the root stays absolute', () => {
|
||||
expect(toProjectRelativeMentionPath('/other/a.ts', '/repo')).toBe('/other/a.ts');
|
||||
});
|
||||
|
||||
test('a sibling directory sharing a prefix is not treated as inside', () => {
|
||||
expect(toProjectRelativeMentionPath('/repo-other/a.ts', '/repo')).toBe('/repo-other/a.ts');
|
||||
});
|
||||
|
||||
test('the root itself is returned unchanged', () => {
|
||||
expect(toProjectRelativeMentionPath('/repo', '/repo')).toBe('/repo');
|
||||
});
|
||||
|
||||
test('with no root the path is left alone', () => {
|
||||
expect(toProjectRelativeMentionPath('/repo/src/a.ts', '')).toBe('/repo/src/a.ts');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Reading a drop's payload.
|
||||
*
|
||||
* Hosts describe a dragged file in incompatible ways: a browser exposes real
|
||||
* `File` entries, VS Code's explorer offers only proprietary data types whose
|
||||
* payloads must be parsed for paths, and OpenChamber's own file tree marks an
|
||||
* internal drag with a private type. These helpers answer the three questions
|
||||
* the composer actually asks of a `DataTransfer`, and are pure so they can be
|
||||
* exercised without a browser.
|
||||
*
|
||||
* `getData` throws in some hosts when called during dragover rather than drop;
|
||||
* every read here is guarded so one unreadable type cannot abort the scan.
|
||||
*/
|
||||
|
||||
import { parseDroppedFileReferences, VS_CODE_DROP_DATA_TYPES } from './filePaths';
|
||||
|
||||
/** Data type marking a drag that started in OpenChamber's own file tree. */
|
||||
export const INTERNAL_FILE_PATH_TYPE = 'application/x-openchamber-file-path';
|
||||
|
||||
/** Data types that, by their presence alone, mean files are being dragged. */
|
||||
const FILE_BEARING_TYPES = [
|
||||
'files',
|
||||
'text/uri-list',
|
||||
'codefiles',
|
||||
INTERNAL_FILE_PATH_TYPE,
|
||||
];
|
||||
|
||||
function readData(dataTransfer: DataTransfer, type: string): string {
|
||||
try {
|
||||
return dataTransfer.getData(type);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this drag carries files at all, used to decide if the composer
|
||||
* should show its drop target. Checked on dragenter/dragover, where payloads
|
||||
* are often unreadable, so the declared types are the primary signal and the
|
||||
* payload scan is the fallback for hosts that declare nothing useful.
|
||||
*/
|
||||
export function hasDraggedFiles(dataTransfer: DataTransfer | null | undefined): boolean {
|
||||
if (!dataTransfer) return false;
|
||||
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
|
||||
|
||||
if (dataTransfer.types) {
|
||||
const lowerTypes = Array.from(dataTransfer.types).map((type) => type.toLowerCase());
|
||||
if (FILE_BEARING_TYPES.some((type) => lowerTypes.includes(type))) return true;
|
||||
if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true;
|
||||
}
|
||||
|
||||
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
|
||||
const payload = readData(dataTransfer, dataType);
|
||||
if (payload && parseDroppedFileReferences(payload).length > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual `File` objects in a drop. `files` is the normal source; `items`
|
||||
* covers hosts that only populate the item list.
|
||||
*/
|
||||
export function collectDroppedFiles(dataTransfer: DataTransfer | null | undefined): File[] {
|
||||
if (!dataTransfer) return [];
|
||||
|
||||
const directFiles = Array.from(dataTransfer.files || []);
|
||||
if (directFiles.length > 0) return directFiles;
|
||||
|
||||
return Array.from(dataTransfer.items || [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* File references from a drop that carries no `File` objects — VS Code hands
|
||||
* over paths and expects the receiver to resolve them itself.
|
||||
*/
|
||||
export function collectDroppedFileUris(dataTransfer: DataTransfer | null | undefined): string[] {
|
||||
if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
|
||||
|
||||
const extracted = new Set<string>();
|
||||
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
|
||||
const rawPayload = readData(dataTransfer, dataType);
|
||||
if (!rawPayload) continue;
|
||||
for (const candidate of parseDroppedFileReferences(rawPayload)) {
|
||||
extracted.add(candidate);
|
||||
}
|
||||
}
|
||||
return Array.from(extracted);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Path handling for composer attachments and dropped files.
|
||||
*
|
||||
* Three representations meet here: what the user sees in the prompt (a
|
||||
* project-relative mention), what the OpenCode server is given (a `file://`
|
||||
* URL), and what a host application hands over on drop (a native path, a
|
||||
* percent-encoded URI, or a JSON payload with either buried inside).
|
||||
*/
|
||||
|
||||
const FILE_URI_PREFIX = 'file://';
|
||||
|
||||
/**
|
||||
* Percent-encode a path for use in a `file://` URL, leaving separators intact
|
||||
* and preserving a Windows drive letter, which must not be encoded.
|
||||
*/
|
||||
export function encodeFilePath(filepath: string): string {
|
||||
let normalized = filepath.replace(/\\/g, '/');
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
return normalized
|
||||
.split('/')
|
||||
.map((segment, index) => {
|
||||
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/** The `file://` URL the server resolves an attachment from. */
|
||||
export function toServerFileUrl(filepath: string): string {
|
||||
const normalized = filepath.replace(/\\/g, '/').trim();
|
||||
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
||||
return normalized;
|
||||
}
|
||||
return `file://${encodeFilePath(normalized)}`;
|
||||
}
|
||||
|
||||
/** POSIX root, UNC share, or Windows drive letter. */
|
||||
export function isLikelyAbsolutePath(value: string): boolean {
|
||||
return value.startsWith('/')
|
||||
|| value.startsWith('\\\\')
|
||||
|| /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim and unquote a candidate, returning it only if it actually looks like a
|
||||
* file reference. A multi-line value is rejected outright: it is a document,
|
||||
* not a path.
|
||||
*/
|
||||
export function toLikelyFileDropReference(value: string): string | null {
|
||||
const trimmed = value.trim().replace(/^['"]+|['"]+$/g, '');
|
||||
if (!trimmed) return null;
|
||||
if (/[\r\n]/.test(trimmed)) return null;
|
||||
if (trimmed.toLowerCase().startsWith(FILE_URI_PREFIX)) return trimmed;
|
||||
if (isLikelyAbsolutePath(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Collect every string in a nested value, bounded so a cyclic-ish payload terminates. */
|
||||
function collectStringLeaves(input: unknown, output: Set<string>, depth = 0): void {
|
||||
if (depth > 6 || input == null) return;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
output.add(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
for (const item of input) collectStringLeaves(item, output, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof input !== 'object') return;
|
||||
|
||||
for (const value of Object.values(input)) collectStringLeaves(value, output, depth + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file references from a drop payload. Hosts disagree on the shape:
|
||||
* a bare path, a newline-separated URI list, or JSON with the paths nested
|
||||
* somewhere inside — so try the text directly, then line by line, then again
|
||||
* over every string found in the parsed JSON.
|
||||
*/
|
||||
export function parseDroppedFileReferences(rawPayload: string): string[] {
|
||||
const extracted = new Set<string>();
|
||||
|
||||
const addCandidatesFromText = (value: string): void => {
|
||||
const direct = toLikelyFileDropReference(value);
|
||||
if (direct) {
|
||||
extracted.add(direct);
|
||||
return;
|
||||
}
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const candidate = toLikelyFileDropReference(line);
|
||||
if (candidate) extracted.add(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
addCandidatesFromText(rawPayload);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawPayload) as unknown;
|
||||
const leaves = new Set<string>();
|
||||
collectStringLeaves(parsed, leaves);
|
||||
for (const leaf of leaves) addCandidatesFromText(leaf);
|
||||
} catch {
|
||||
// Not JSON; the direct and line-wise passes already covered it.
|
||||
}
|
||||
|
||||
return Array.from(extracted);
|
||||
}
|
||||
|
||||
/** Turn a dropped `file://` URI back into a plain path. */
|
||||
export function normalizeDroppedPath(rawPath: string): string {
|
||||
const input = rawPath.trim();
|
||||
if (!input.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
let pathname = decodeURIComponent(new URL(input).pathname || '');
|
||||
// file:///C:/... parses with a leading slash before the drive letter.
|
||||
if (/^\/[A-Za-z]:\//.test(pathname)) {
|
||||
pathname = pathname.slice(1);
|
||||
}
|
||||
return pathname || input;
|
||||
} catch {
|
||||
const stripped = input.replace(/^file:\/\//i, '');
|
||||
try {
|
||||
return decodeURIComponent(stripped);
|
||||
} catch {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a directory or file path for comparison: forward slashes, no
|
||||
* trailing separator. Returns null for anything blank, so callers can treat
|
||||
* "no path" and "unusable path" the same way.
|
||||
*/
|
||||
export function normalizePath(value?: string | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const normalized = trimmed.replace(/\\/g, '/');
|
||||
if (normalized === '/') return '/';
|
||||
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express an absolute path relative to the project root, so the prompt carries
|
||||
* the path the user recognizes. Paths outside the root are left absolute.
|
||||
*/
|
||||
export function toProjectRelativeMentionPath(absolutePath: string, root: string): string {
|
||||
const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
|
||||
const normalizedRoot = (root || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!normalizedRoot) return normalizedAbsolutePath;
|
||||
if (normalizedAbsolutePath === normalizedRoot) return normalizedAbsolutePath;
|
||||
|
||||
const rootWithSlash = `${normalizedRoot}/`;
|
||||
return normalizedAbsolutePath.startsWith(rootWithSlash)
|
||||
? normalizedAbsolutePath.slice(rootWithSlash.length)
|
||||
: normalizedAbsolutePath;
|
||||
}
|
||||
|
||||
/** Data transfer types VS Code uses when dragging from its explorer. */
|
||||
export const VS_CODE_DROP_DATA_TYPES = [
|
||||
'CodeFiles',
|
||||
'codefiles',
|
||||
'application/vnd.code.tree',
|
||||
'application/vnd.code.tree.explorer',
|
||||
'text/uri-list',
|
||||
'text/plain',
|
||||
];
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* The composer's text editor.
|
||||
*
|
||||
* This replaces the transparent-textarea-over-mirror-div arrangement the
|
||||
* composer used before. That arrangement could only paint styles which do not
|
||||
* change glyph advance width — colour, background, underline — because any
|
||||
* metric change made the mirror drift out from under the caret. Bold, italic
|
||||
* and any width-affecting affordance were therefore impossible, and the
|
||||
* overlay had to be disabled outright on mobile, where wrapped text drifted
|
||||
* anyway.
|
||||
*
|
||||
* CodeMirror owns the text and the caret together, so there is no second layer
|
||||
* to keep aligned. The document remains a plain string — `getValue()` is
|
||||
* exactly what gets sent — so nothing downstream has to serialize a rich
|
||||
* document model back into a prompt.
|
||||
*
|
||||
* The component is a controlled primitive: it renders `value`, reports edits,
|
||||
* and exposes an imperative handle for the caret-level operations the composer
|
||||
* performs (insert a mention, restore a draft, replace a token). Every policy
|
||||
* decision — what a key means, which picker opens, when to send — stays with
|
||||
* the caller.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { history, historyKeymap, standardKeymap } from '@codemirror/commands';
|
||||
import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state';
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
keymap,
|
||||
placeholder as placeholderExtension,
|
||||
type KeyBinding,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
|
||||
export interface ComposerSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ComposerChange {
|
||||
value: string;
|
||||
selection: ComposerSelection;
|
||||
/** True when the edit came from a paste rather than typing. */
|
||||
fromPaste: boolean;
|
||||
/** The text this edit inserted, empty for deletions. */
|
||||
insertedText: string;
|
||||
}
|
||||
|
||||
export interface ComposerEditorHandle {
|
||||
focus(options?: { preventScroll?: boolean }): void;
|
||||
blur(): void;
|
||||
isFocused(): boolean;
|
||||
getValue(): string;
|
||||
getSelection(): ComposerSelection;
|
||||
setSelection(start: number, end?: number): void;
|
||||
selectAll(): void;
|
||||
/** Replace the current selection, leaving the caret after the insertion. */
|
||||
insertText(text: string): void;
|
||||
/** Replace an explicit range; the caret lands at `caret` or after the text. */
|
||||
replaceRange(from: number, to: number, text: string, caret?: number): void;
|
||||
/** Viewport coordinates of the caret, for positioning popups. */
|
||||
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
|
||||
/** The scrollable element, for measuring and scroll compensation. */
|
||||
getScrollDOM(): HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface ComposerEditorProps {
|
||||
value: string;
|
||||
onChange: (change: ComposerChange) => void;
|
||||
/** Caret or selection moved without the document changing. */
|
||||
onSelectionChange?: (selection: ComposerSelection) => void;
|
||||
/**
|
||||
* Key press before CodeMirror handles it. Return true to consume the
|
||||
* event — this is where the composer routes autocomplete navigation,
|
||||
* message history and send.
|
||||
*/
|
||||
onKeyDown?: (event: KeyboardEvent) => boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
languageContext: ComposerLanguageContext;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
spellCheck?: boolean;
|
||||
/** Mobile keyboards; ignored on desktop. */
|
||||
autoCorrect?: boolean;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
/** Fill the available height instead of growing with the content. */
|
||||
fillContainer?: boolean;
|
||||
/** Lines of text shown before the editor starts scrolling. */
|
||||
maxLines?: number;
|
||||
/**
|
||||
* Selector of the ancestor the composer must never outgrow. The cap is
|
||||
* measured — the ancestor's height minus the chrome around the editor,
|
||||
* both read from the DOM — and the smaller of it and `maxLines` wins.
|
||||
*/
|
||||
boundSelector?: string;
|
||||
/** Breathing room kept between the grown composer and the bound's edge. */
|
||||
boundGapPx?: number;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/**
|
||||
* Keeps the underlying view alive across unmounts. Supply one from a parent
|
||||
* that outlives the swap; without it the view is built and destroyed with
|
||||
* the component, which is correct but expensive on an interaction path.
|
||||
*/
|
||||
viewStore?: ComposerEditorViewStore;
|
||||
'aria-label'?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The text inserted by a transaction, used to tell a typed `@` from a pasted
|
||||
* one. CodeMirror reports the change set directly, so this needs none of the
|
||||
* prefix/suffix diffing a textarea's `onChange` required.
|
||||
*/
|
||||
function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: number, toA: number, fromB: number, toB: number, inserted: { toString(): string }) => void) => void } }): string {
|
||||
let inserted = '';
|
||||
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, text) => {
|
||||
inserted += text.toString();
|
||||
});
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compartments are configuration keys, not per-view state, so one set can serve
|
||||
* every editor. They live at module scope because a kept-alive view outlives
|
||||
* the component that created it: per-instance compartments would be unknown to
|
||||
* the reused view's configuration, and reconfiguring it would throw.
|
||||
*/
|
||||
const editableCompartment = new Compartment();
|
||||
const placeholderCompartment = new Compartment();
|
||||
|
||||
export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEditorProps>(
|
||||
function ComposerEditor(props, ref) {
|
||||
const {
|
||||
value,
|
||||
languageContext,
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
boundSelector,
|
||||
boundGapPx = 0,
|
||||
className,
|
||||
contentClassName,
|
||||
} = props;
|
||||
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = React.useRef<EditorView | null>(null);
|
||||
|
||||
// Callbacks reach the CodeMirror extensions through a ref: the view is
|
||||
// built once and must not be torn down when a handler identity changes,
|
||||
// which would drop focus mid-typing. When a view store is supplied the
|
||||
// ref lives there, so a kept-alive view keeps calling into whichever
|
||||
// component instance is currently mounted rather than a dead one.
|
||||
const localHandlersRef = React.useRef(props);
|
||||
const store = props.viewStore ?? null;
|
||||
if (store && !store.handlers) store.handlers = { current: props };
|
||||
const handlersRef = store?.handlers ?? localHandlersRef;
|
||||
handlersRef.current = props;
|
||||
|
||||
// A layout effect, not a passive one: the mobile composer expands with
|
||||
// `flushSync` and focuses the editor on the next line, still inside the
|
||||
// tap's call stack, because that is the only way a mobile browser
|
||||
// raises the keyboard. flushSync commits layout effects but makes no
|
||||
// promise about passive ones, so creating the view there would leave
|
||||
// nothing to focus — the keyboard would rise later, from some other
|
||||
// path, and the composer would appear to transform only once it moved.
|
||||
React.useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
// A kept view is re-attached rather than rebuilt. Its extensions
|
||||
// already read through the shared handlers ref, so it needs nothing
|
||||
// from this instance beyond a parent to live in; the effects below
|
||||
// re-apply editable, placeholder, value and language context.
|
||||
const keptView = store?.view;
|
||||
if (keptView) {
|
||||
host.appendChild(keptView.dom);
|
||||
// Measurements taken while detached are meaningless; the view
|
||||
// re-reads its geometry now that it is back in the document.
|
||||
keptView.requestMeasure();
|
||||
viewRef.current = keptView;
|
||||
return () => {
|
||||
keptView.dom.remove();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
const interceptKeys: KeyBinding[] = [{
|
||||
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
|
||||
}];
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: handlersRef.current.value,
|
||||
extensions: [
|
||||
history(),
|
||||
// `drawSelection()` must stay even though the native
|
||||
// selection is what actually shows (see the theme's
|
||||
// comment on `composerNativeSelectionExtension`):
|
||||
// removing it makes CodeMirror enforce cursor
|
||||
// association on the native selection, which iOS
|
||||
// answers with severe input lag.
|
||||
drawSelection(),
|
||||
composerNativeSelectionExtension,
|
||||
EditorView.lineWrapping,
|
||||
// Highest precedence: the composer's own keys must win
|
||||
// over CodeMirror's defaults (Enter sends, ArrowUp
|
||||
// walks history, Escape closes a picker).
|
||||
Prec.highest(keymap.of(interceptKeys)),
|
||||
keymap.of([...standardKeymap, ...historyKeymap]),
|
||||
composerLanguage(handlersRef.current.languageContext),
|
||||
editableCompartment.of(
|
||||
EditorView.editable.of(handlersRef.current.editable ?? true),
|
||||
),
|
||||
placeholderCompartment.of(
|
||||
placeholderExtension(handlersRef.current.placeholder ?? ''),
|
||||
),
|
||||
composerEditorTheme,
|
||||
EditorView.updateListener.of((update) => {
|
||||
const handlers = handlersRef.current;
|
||||
const selection = readSelection(update.state);
|
||||
|
||||
if (update.docChanged) {
|
||||
const fromPaste = update.transactions.some(
|
||||
(transaction) => transaction.isUserEvent('input.paste'),
|
||||
);
|
||||
let insertedText = '';
|
||||
for (const transaction of update.transactions) {
|
||||
insertedText += insertedTextOf(transaction);
|
||||
}
|
||||
handlers.onChange({
|
||||
value: update.state.doc.toString(),
|
||||
selection,
|
||||
fromPaste,
|
||||
insertedText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
handlers.onSelectionChange?.(selection);
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => { handlersRef.current.onFocus?.(); return false; },
|
||||
blur: () => { handlersRef.current.onBlur?.(); return false; },
|
||||
paste: (event) => { handlersRef.current.onPaste?.(event); return false; },
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||
...(handlersRef.current['aria-label']
|
||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||
: {}),
|
||||
}),
|
||||
] satisfies Extension[],
|
||||
}),
|
||||
parent: host,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
if (store) store.view = view;
|
||||
|
||||
return () => {
|
||||
viewRef.current = null;
|
||||
// A stored view is detached, not destroyed: the store owns its
|
||||
// lifetime now, and whoever owns the store ends it.
|
||||
if (store) {
|
||||
view.dom.remove();
|
||||
return;
|
||||
}
|
||||
view.destroy();
|
||||
};
|
||||
// Created once: every changing input is applied through a
|
||||
// dispatch below rather than by rebuilding the view.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Controlled value: only write back when the prop and the document
|
||||
// genuinely differ, otherwise every keystroke would round-trip and
|
||||
// reset the caret.
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current === value) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
// "add to chat", dictation insert) lands the caret at the END,
|
||||
// matching what a plain textarea did when its value was
|
||||
// replaced. Every rewrite that reaches here appends or
|
||||
// replaces wholesale; keeping the old caret instead left it
|
||||
// stranded before the inserted text, and the next insertion
|
||||
// or keystroke landed inside the previous one.
|
||||
selection: { anchor: value.length },
|
||||
});
|
||||
// A large insert can push the caret below the fold, and a
|
||||
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
|
||||
// heights are still estimates during the update, and the
|
||||
// grow-with-content effect applies the scroller's max-height cap
|
||||
// through a ResizeObserver a frame later — at scroll time the
|
||||
// overflow does not exist yet, so the scroller stays at the top.
|
||||
// The caret is at the end here, so once the layout has settled
|
||||
// (two frames: one for the cap, one after it) pin the scroller to
|
||||
// the bottom.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (viewRef.current !== view) return;
|
||||
view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
|
||||
});
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
viewRef.current?.dispatch({ effects: setLanguageContext.of(languageContext) });
|
||||
}, [languageContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: editableCompartment.reconfigure(EditorView.editable.of(editable)),
|
||||
});
|
||||
}, [editable]);
|
||||
|
||||
React.useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: placeholderCompartment.reconfigure(placeholderExtension(placeholder ?? '')),
|
||||
});
|
||||
}, [placeholder]);
|
||||
|
||||
// Grow with the content up to `maxLines`, then scroll. The limit is
|
||||
// measured from the rendered line height rather than assumed, so it
|
||||
// tracks the composer's responsive typography.
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!view || !host) return;
|
||||
|
||||
// Filling the container means there is no line limit — and the
|
||||
// limit from the collapsed composer has to be released, or the
|
||||
// expanded editor keeps scrolling inside an invisible eight-line
|
||||
// window while the rest of the surface sits empty.
|
||||
if (fillContainer) {
|
||||
view.scrollDOM.style.maxHeight = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const boundEl = boundSelector ? host.closest(boundSelector) : null;
|
||||
// The bound's direct child our editor lives in: its height minus
|
||||
// the scroller's is exactly the chrome around the editor — form
|
||||
// paddings, model row, footer, attachment chips — measured live,
|
||||
// so the cap needs no estimate of what surrounds the editor.
|
||||
let branch: HTMLElement | null = null;
|
||||
if (boundEl) {
|
||||
branch = host;
|
||||
while (branch.parentElement && branch.parentElement !== boundEl) {
|
||||
branch = branch.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
const applyLimit = () => {
|
||||
const lineHeight = parseFloat(
|
||||
getComputedStyle(view.contentDOM).lineHeight || '',
|
||||
);
|
||||
if (!Number.isFinite(lineHeight) || lineHeight <= 0) return;
|
||||
let cap = lineHeight * maxLines;
|
||||
if (boundEl && branch) {
|
||||
const chrome = branch.offsetHeight - view.scrollDOM.offsetHeight;
|
||||
const available = boundEl.clientHeight - chrome - boundGapPx;
|
||||
if (available > 0) cap = Math.min(cap, available);
|
||||
}
|
||||
const next = `${cap}px`;
|
||||
// The scroller growing re-fires the observer with an unchanged
|
||||
// result; writing only on change keeps that loop silent.
|
||||
if (view.scrollDOM.style.maxHeight !== next) {
|
||||
view.scrollDOM.style.maxHeight = next;
|
||||
}
|
||||
};
|
||||
|
||||
applyLimit();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(applyLimit);
|
||||
observer.observe(host);
|
||||
if (branch) observer.observe(branch);
|
||||
if (boundEl) observer.observe(boundEl);
|
||||
return () => observer.disconnect();
|
||||
}, [boundGapPx, boundSelector, fillContainer, maxLines]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const content = view.contentDOM;
|
||||
content.setAttribute('spellcheck', String(spellCheck));
|
||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
||||
content.setAttribute('autocapitalize', autoCapitalize);
|
||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
||||
|
||||
/**
|
||||
* The composer box is bigger than its text: it carries padding, and in
|
||||
* focus mode it fills the surface. Clicking that empty space has always
|
||||
* put the caret in the text — with a textarea the element itself filled
|
||||
* the box, so the browser did it. CodeMirror's content element does not
|
||||
* extend into the padding, so the click has to be forwarded.
|
||||
*/
|
||||
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
|
||||
handleComposerHostMouseDown(viewRef.current, event);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
|
||||
focus(options) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
// preventScroll matters on mobile, where the browser's own
|
||||
// scroll-into-view fights the keyboard choreography.
|
||||
view.contentDOM.focus({ preventScroll: options?.preventScroll });
|
||||
},
|
||||
blur() {
|
||||
viewRef.current?.contentDOM.blur();
|
||||
},
|
||||
isFocused() {
|
||||
return viewRef.current?.hasFocus ?? false;
|
||||
},
|
||||
getValue() {
|
||||
return viewRef.current?.state.doc.toString() ?? '';
|
||||
},
|
||||
getSelection() {
|
||||
const view = viewRef.current;
|
||||
return view ? readSelection(view.state) : { start: 0, end: 0 };
|
||||
},
|
||||
setSelection(start, end = start) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const max = view.state.doc.length;
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: Math.min(Math.max(start, 0), max),
|
||||
head: Math.min(Math.max(end, 0), max),
|
||||
},
|
||||
});
|
||||
},
|
||||
selectAll() {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
},
|
||||
insertText(text) {
|
||||
const view = viewRef.current;
|
||||
if (!view || !text) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
replaceRange(from, to, text, caret) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret ?? from + text.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
caretCoords(position) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return null;
|
||||
const pos = position ?? view.state.selection.main.head;
|
||||
const coords = view.coordsAtPos(pos);
|
||||
return coords
|
||||
? { top: coords.top, bottom: coords.bottom, left: coords.left }
|
||||
: null;
|
||||
},
|
||||
getScrollDOM() {
|
||||
return viewRef.current?.scrollDOM ?? null;
|
||||
},
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-testid={props['data-testid']}
|
||||
data-chat-input="true"
|
||||
onMouseDown={handleHostMouseDown}
|
||||
className={cn(
|
||||
'composer-editor w-full',
|
||||
// The editor fills the host so its content box can cover
|
||||
// the whole clickable area rather than just the text.
|
||||
'[&_.cm-editor]:h-full',
|
||||
fillContainer && 'flex min-h-0 flex-1 flex-col',
|
||||
className,
|
||||
contentClassName,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function readSelection(state: EditorState): ComposerSelection {
|
||||
const range = state.selection.main;
|
||||
return { start: range.from, end: range.to };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import type { ComposerLanguageContext } from '../../language/tokenize';
|
||||
import { composerLanguage, setLanguageContext } from '../composerLanguage';
|
||||
|
||||
const context = (overrides: Partial<ComposerLanguageContext> = {}): ComposerLanguageContext => ({
|
||||
inputMode: 'normal',
|
||||
knownAgentNames: new Set(['build']),
|
||||
confirmedMentions: new Set(),
|
||||
knownSlashNames: new Set(['review']),
|
||||
knownSnippetTriggers: new Set(['sig']),
|
||||
attachmentFilenames: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stateWith = (doc: string, ctx = context()) =>
|
||||
EditorState.create({ doc, extensions: composerLanguage(ctx) });
|
||||
|
||||
/** Every decorated stretch as [text, class]. */
|
||||
const decorations = (state: EditorState) => {
|
||||
const found: Array<[string, string]> = [];
|
||||
const set = state.facet(EditorView.decorations)
|
||||
.map((source) => (typeof source === 'function' ? null : source))
|
||||
.find(Boolean);
|
||||
if (!set) return found;
|
||||
const iterator = set.iter();
|
||||
while (iterator.value) {
|
||||
const spec = iterator.value.spec as { class?: string };
|
||||
found.push([state.doc.sliceString(iterator.from, iterator.to), spec.class ?? '']);
|
||||
iterator.next();
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const decoratedText = (state: EditorState) => decorations(state).map(([text]) => text);
|
||||
|
||||
describe('composerLanguage — initial decorations', () => {
|
||||
test('decorates the references it knows about', () => {
|
||||
expect(decoratedText(stateWith('ask @build to /review'))).toEqual(['@build', '/review']);
|
||||
});
|
||||
|
||||
test('leaves unknown tokens undecorated', () => {
|
||||
expect(decoratedText(stateWith('ask @stranger to /nothing'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('decorates markdown structure', () => {
|
||||
expect(decoratedText(stateWith('# Title'))).toEqual(['#', 'Title']);
|
||||
});
|
||||
|
||||
test('plain prose gets no decorations at all', () => {
|
||||
expect(decoratedText(stateWith('just a sentence'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('an empty document is fine', () => {
|
||||
expect(decoratedText(stateWith(''))).toEqual([]);
|
||||
});
|
||||
|
||||
test('shell mode disables the language', () => {
|
||||
expect(decoratedText(stateWith('@build /review', context({ inputMode: 'shell' }))))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test('decorated spans carry the shared highlight classes', () => {
|
||||
const [[, agentClass]] = decorations(stateWith('@build'));
|
||||
expect(agentClass).toContain('status-success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerLanguage — updates', () => {
|
||||
test('editing the document retokenizes', () => {
|
||||
const state = stateWith('hello');
|
||||
const next = state.update({
|
||||
changes: { from: 5, insert: ' @build' },
|
||||
}).state;
|
||||
expect(decoratedText(next)).toEqual(['@build']);
|
||||
});
|
||||
|
||||
test('deleting a reference removes its decoration', () => {
|
||||
const state = stateWith('@build hi');
|
||||
const next = state.update({ changes: { from: 0, to: 7 } }).state;
|
||||
expect(decoratedText(next)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a new registry repaints without touching the document', () => {
|
||||
const state = stateWith('ask @deploy');
|
||||
expect(decoratedText(state)).toEqual([]);
|
||||
|
||||
const next = state.update({
|
||||
effects: setLanguageContext.of(context({ knownAgentNames: new Set(['deploy']) })),
|
||||
}).state;
|
||||
expect(decoratedText(next)).toEqual(['@deploy']);
|
||||
expect(next.doc.toString()).toBe('ask @deploy');
|
||||
});
|
||||
|
||||
test('a transaction that changes neither keeps the same decoration set', () => {
|
||||
const state = stateWith('@build');
|
||||
const next = state.update({ selection: { anchor: 0 } }).state;
|
||||
expect(decoratedText(next)).toEqual(['@build']);
|
||||
});
|
||||
|
||||
test('the document stays the plain string that gets sent', () => {
|
||||
const state = stateWith('# Title\n@build /review #sig');
|
||||
expect(state.doc.toString()).toBe('# Title\n@build /review #sig');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
|
||||
import { focusChatInput } from '../dom';
|
||||
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
test('focuses the CodeMirror chat input content', () => {
|
||||
let selector = '';
|
||||
let focused = false;
|
||||
globalThis.document = {
|
||||
querySelector: (value: string) => {
|
||||
selector = value;
|
||||
return { focus: () => { focused = true; } };
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
focusChatInput();
|
||||
|
||||
expect(selector).toBe('[data-chat-input="true"] .cm-content');
|
||||
expect(focused).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState, type TransactionSpec } from '@codemirror/state';
|
||||
import { drawSelection, type EditorView } from '@codemirror/view';
|
||||
|
||||
import { handleComposerHostMouseDown } from '../hostMouseDown';
|
||||
|
||||
const padding = {} as Node;
|
||||
const shell = {} as Node;
|
||||
const text = {} as Node;
|
||||
|
||||
class ComposerViewHarness {
|
||||
state = EditorState.create({ doc: 'hello', extensions: [drawSelection()] });
|
||||
contentActive = false;
|
||||
windowActive = true;
|
||||
caretPainted = false;
|
||||
prevented = false;
|
||||
position: number | null = 2;
|
||||
readonly contentDOM = {
|
||||
isContentEditable: true,
|
||||
contains: (target: Node) => target === text,
|
||||
};
|
||||
|
||||
focus(): void {
|
||||
this.windowActive = true;
|
||||
this.contentActive = true;
|
||||
}
|
||||
|
||||
dispatch(spec: TransactionSpec): void {
|
||||
this.state = this.state.update(spec).state;
|
||||
this.caretPainted = this.windowActive && this.contentActive;
|
||||
}
|
||||
|
||||
posAtCoords(): number | null {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
mouseDown(target: Node): void {
|
||||
handleComposerHostMouseDown(this as unknown as EditorView, {
|
||||
target,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
preventDefault: () => { this.prevented = true; },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('composer host mouse down', () => {
|
||||
test('focuses before the first padding selection update paints the caret', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
|
||||
view.mouseDown(padding);
|
||||
|
||||
expect(view.prevented).toBe(true);
|
||||
expect(view.contentActive).toBe(true);
|
||||
expect(view.state.selection.main.head).toBe(2);
|
||||
expect(view.caretPainted).toBe(true);
|
||||
});
|
||||
|
||||
test('repaints after window reactivation even when focus bookkeeping is stale', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
// The content remains active while CodeMirror's last notified focus is
|
||||
// stale; reactivating the window does not itself repaint drawSelection.
|
||||
view.contentActive = true;
|
||||
view.windowActive = false;
|
||||
view.caretPainted = false;
|
||||
|
||||
view.mouseDown(shell);
|
||||
|
||||
expect(view.windowActive).toBe(true);
|
||||
expect(view.caretPainted).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back to the document end when padding has no mapped position', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
view.position = null;
|
||||
|
||||
view.mouseDown(padding);
|
||||
|
||||
expect(view.state.selection.main.head).toBe(5);
|
||||
});
|
||||
|
||||
test('leaves native text selection and read-only editors alone', () => {
|
||||
const textView = new ComposerViewHarness();
|
||||
textView.mouseDown(text);
|
||||
expect(textView.prevented).toBe(false);
|
||||
expect(textView.contentActive).toBe(false);
|
||||
|
||||
const readOnlyView = new ComposerViewHarness();
|
||||
readOnlyView.state = EditorState.create({
|
||||
doc: 'hello',
|
||||
extensions: [EditorState.readOnly.of(true), drawSelection()],
|
||||
});
|
||||
readOnlyView.mouseDown(padding);
|
||||
expect(readOnlyView.prevented).toBe(false);
|
||||
expect(readOnlyView.contentActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import {
|
||||
COMPOSER_EDITOR_THEME_SPEC,
|
||||
NATIVE_SELECTION_THEME_SPEC,
|
||||
composerEditorTheme,
|
||||
composerNativeSelectionExtension,
|
||||
} from '../theme';
|
||||
|
||||
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
|
||||
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
describe('composerEditorTheme', () => {
|
||||
/**
|
||||
* EditorView.theme compiles its selectors when this module is imported and
|
||||
* throws RangeError on a scope it was not given — `&light` and `&dark`
|
||||
* among them, despite both appearing throughout CodeMirror's own base
|
||||
* theme. A build and a type-check both pass happily on that mistake; it
|
||||
* surfaces only in the running app, where it takes the composer down.
|
||||
*/
|
||||
test('its selectors compile and the theme can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerEditorTheme] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer runs `drawSelection()`, which hides the native caret with
|
||||
* `caret-color: transparent !important` and draws a `.cm-cursor` element
|
||||
* instead. Styling `caret-color` looks correct and does nothing, leaving
|
||||
* CodeMirror's hard-coded black cursor on dark themes.
|
||||
*/
|
||||
test('the caret is coloured where it is drawn, not on the native caret', () => {
|
||||
expect(selectors.some((selector) => selector.includes('.cm-cursor'))).toBe(true);
|
||||
expect(declarations.includes('caretColor')).toBe(false);
|
||||
});
|
||||
|
||||
test('the drawn caret follows the theme rather than a fixed colour', () => {
|
||||
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
|
||||
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
|
||||
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
|
||||
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
|
||||
* latter. `&.cm-editor` matches it.
|
||||
*/
|
||||
test('the caret rule is specific enough to beat the base theme', () => {
|
||||
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
|
||||
expect(cursorRule!.startsWith('&.cm-editor')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Same trap as the caret, one layer over: `drawSelection()` paints its own
|
||||
* selection and CodeMirror styles the focused case through a six-class
|
||||
* selector. A shorter rule silently loses and the selection renders in
|
||||
* CodeMirror's stock lavender, which buries the token colours.
|
||||
*/
|
||||
test('the focused selection is styled at the depth CodeMirror uses', () => {
|
||||
const focusedRule = selectors.find((selector) =>
|
||||
selector.includes('.cm-focused') && selector.includes('.cm-selectionBackground'));
|
||||
expect(focusedRule).toBeDefined();
|
||||
expect(focusedRule!.includes('.cm-scroller')).toBe(true);
|
||||
expect(focusedRule!.includes('.cm-selectionLayer')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* An unknown custom property makes the whole declaration invalid rather
|
||||
* than falling back to something visible, so a misspelled token reads as
|
||||
* "this element was never styled". `color` inherits, which is how a
|
||||
* camelCased `--surface-mutedForeground` left the placeholder at full text
|
||||
* brightness while looking perfectly correct in the source.
|
||||
*/
|
||||
test('every theme token is kebab-case, as the theme emits them', () => {
|
||||
const tokens = [...declarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
|
||||
expect(tokens.length > 0).toBe(true);
|
||||
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
|
||||
});
|
||||
|
||||
test('the selection is translucent so token colours survive it', () => {
|
||||
const rules = selectors
|
||||
.filter((selector) => selector.includes('.cm-selectionBackground'))
|
||||
.map((selector) =>
|
||||
(COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[selector]);
|
||||
expect(rules.length > 0).toBe(true);
|
||||
for (const rule of rules) {
|
||||
expect(rule.background.includes('transparent')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerNativeSelectionTheme', () => {
|
||||
const nativeSelectors = Object.keys(NATIVE_SELECTION_THEME_SPEC);
|
||||
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device layers this over `drawSelection()`: the native selection
|
||||
* paints over token backgrounds (the painted layer is hidden behind them)
|
||||
* and iOS attaches its selection handles to it. `drawSelection()` must
|
||||
* NOT be removed for that: without it CodeMirror starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text, and
|
||||
* iOS answers those programmatic selection moves with severe input lag.
|
||||
*/
|
||||
test('it compiles and can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerNativeSelectionExtension] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* `drawSelection()` hides the native selection through a `Prec.highest`
|
||||
* theme with `!important` on `.cm-line ::selection`. Winning that back
|
||||
* needs both `!important` and strictly more specificity, because the
|
||||
* mount order of two highest-precedence themes is not something to bet
|
||||
* on.
|
||||
*/
|
||||
test('the native selection is re-shown with enough weight to win', () => {
|
||||
const rule = nativeSelectors.find((selector) => selector.includes('::selection'));
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.includes('.cm-content')).toBe(true);
|
||||
expect(rule!.includes('.cm-line')).toBe(true);
|
||||
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
|
||||
expect(value.backgroundColor.includes('!important')).toBe(true);
|
||||
expect(value.backgroundColor.includes('transparent')).toBe(true);
|
||||
});
|
||||
|
||||
test('the painted selection layer is hidden so highlights do not stack', () => {
|
||||
const rule = nativeSelectors.find((selector) => selector.includes('.cm-selectionLayer'));
|
||||
expect(rule).toBeDefined();
|
||||
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
|
||||
expect(value.display).toBe('none');
|
||||
});
|
||||
|
||||
/**
|
||||
* iOS colours its selection drag handles from the caret colour. With
|
||||
* `drawSelection()`'s `caret-color: transparent !important` in effect the
|
||||
* handles are drawn — invisibly. The native caret must come back with
|
||||
* enough weight to win, and the drawn cursor layer must go so there are
|
||||
* not two carets.
|
||||
*
|
||||
* BUT a visible native caret makes WebKit re-render its caret UI after
|
||||
* every keystroke's decoration redraw — severe input lag. Both rules are
|
||||
* therefore scoped to `.oc-native-range`, which only exists while a range
|
||||
* is selected (when there is no caret to lag on).
|
||||
*/
|
||||
test('the native caret is re-enabled, since the handles take its colour', () => {
|
||||
const rule = nativeSelectors.find((selector) =>
|
||||
selector.includes('.cm-content')
|
||||
&& (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector].caretColor);
|
||||
expect(rule).toBeDefined();
|
||||
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
|
||||
expect(value.caretColor.startsWith('var(--')).toBe(true);
|
||||
expect(value.caretColor.includes('!important')).toBe(true);
|
||||
expect(rule!.includes('&.cm-editor')).toBe(true);
|
||||
});
|
||||
|
||||
test('the native caret shows only while a range is selected', () => {
|
||||
for (const selector of nativeSelectors) {
|
||||
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector];
|
||||
if (value.caretColor) {
|
||||
expect(selector.includes('.oc-native-range')).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the drawn cursor layer is hidden so there are not two carets', () => {
|
||||
const rule = nativeSelectors.find((selector) => selector.includes('.cm-cursorLayer'));
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.includes('.oc-native-range')).toBe(true);
|
||||
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
|
||||
expect(value.display).toBe('none');
|
||||
});
|
||||
|
||||
test('every theme token is kebab-case, as the theme emits them', () => {
|
||||
const tokens = [...nativeDeclarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
|
||||
expect(tokens.length > 0).toBe(true);
|
||||
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The composer's prompt language as a CodeMirror extension.
|
||||
*
|
||||
* `tokenizeComposer` already answers "what does this text mean"; this module
|
||||
* is the thin adapter that turns its ranges into mark decorations and keeps
|
||||
* them in sync with the document and with the workspace registries.
|
||||
*
|
||||
* Why this replaces the mirror overlay: a transparent textarea painted over a
|
||||
* mirror div can only use styles that do not change glyph advance width, or
|
||||
* the two layers drift apart and the caret lands in the wrong place. That is
|
||||
* why bold and italic were never highlighted, and why the overlay had to be
|
||||
* switched off entirely on mobile. CodeMirror owns the caret and the text, so
|
||||
* there is no second layer to keep aligned and no metric restriction.
|
||||
*/
|
||||
|
||||
import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
|
||||
|
||||
import { resolveHighlightSegments, DEFAULT_HIGHLIGHT_CLASS } from '../../composerHighlight';
|
||||
import { tokenizeComposer, type ComposerLanguageContext } from '../language/tokenize';
|
||||
|
||||
/**
|
||||
* Replace the workspace knowledge the tokenizer resolves against. Dispatched
|
||||
* when the agent, command, skill, snippet or attachment registries change —
|
||||
* not on every keystroke, which only changes the document.
|
||||
*/
|
||||
export const setLanguageContext = StateEffect.define<ComposerLanguageContext>();
|
||||
|
||||
/**
|
||||
* The context lives in editor state rather than in a closure so the decoration
|
||||
* field can recompute from `(document, context)` alone, and so a context change
|
||||
* repaints without remounting the view.
|
||||
*/
|
||||
const languageContextField = StateField.define<ComposerLanguageContext>({
|
||||
create: () => EMPTY_CONTEXT,
|
||||
update(value, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(setLanguageContext)) return effect.value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
export const EMPTY_CONTEXT: ComposerLanguageContext = {
|
||||
inputMode: 'normal',
|
||||
knownAgentNames: new Set(),
|
||||
confirmedMentions: new Set(),
|
||||
knownSlashNames: new Set(),
|
||||
knownSnippetTriggers: new Set(),
|
||||
attachmentFilenames: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorations for the whole document. The composer holds a prompt, not a
|
||||
* source file: it is short enough that a full retokenize per change is
|
||||
* cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
* and the send path reading the exact same grammar.
|
||||
*/
|
||||
function buildDecorations(text: string, context: ComposerLanguageContext): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (const segment of resolveHighlightSegments(text, tokenizeComposer(text, context))) {
|
||||
// Unstyled stretches need no decoration — the editor's own base text
|
||||
// color already renders them.
|
||||
if (segment.className === DEFAULT_HIGHLIGHT_CLASS) continue;
|
||||
builder.add(segment.start, segment.end, Decoration.mark({ class: segment.className }));
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
const decorationField = StateField.define<DecorationSet>({
|
||||
create: (state) => buildDecorations(state.doc.toString(), state.field(languageContextField)),
|
||||
update(value, transaction) {
|
||||
const contextChanged = transaction.effects.some((effect) => effect.is(setLanguageContext));
|
||||
if (!transaction.docChanged && !contextChanged) return value;
|
||||
return buildDecorations(
|
||||
transaction.state.doc.toString(),
|
||||
transaction.state.field(languageContextField),
|
||||
);
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer language extension. Install once; feed it registry updates with
|
||||
* `setLanguageContext`.
|
||||
*/
|
||||
export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEXT) {
|
||||
return [
|
||||
languageContextField.init(() => initial),
|
||||
decorationField,
|
||||
];
|
||||
}
|
||||
|
||||
/** The context currently in effect, for callers that need to read it back. */
|
||||
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
|
||||
return view.state.field(languageContextField);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const CHAT_INPUT_EDITOR_SELECTOR = '[data-chat-input="true"] .cm-content';
|
||||
|
||||
export function focusChatInput(): void {
|
||||
document.querySelector<HTMLElement>(CHAT_INPUT_EDITOR_SELECTOR)?.focus();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
type ComposerHostMouseDownEvent = Pick<
|
||||
MouseEvent,
|
||||
'target' | 'clientX' | 'clientY' | 'preventDefault'
|
||||
>;
|
||||
|
||||
export function handleComposerHostMouseDown(
|
||||
view: EditorView | null,
|
||||
event: ComposerHostMouseDownEvent,
|
||||
): void {
|
||||
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
|
||||
// A click that already landed in the text needs no help, and
|
||||
// forwarding it would break drag-selection.
|
||||
if (view.contentDOM.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
|
||||
?? view.state.doc.length;
|
||||
// Focus before dispatching so CodeMirror updates the drawn caret's
|
||||
// visibility while applying the selection update.
|
||||
view.focus();
|
||||
view.dispatch({ selection: { anchor: position } });
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The composer editor's layout, typography and caret.
|
||||
*
|
||||
* Token colours are not here: they come from the shared highlight classes the
|
||||
* language layer emits, so the composer and the message list stay in step.
|
||||
*/
|
||||
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* Exported for the regression test, which asserts the caret is styled where it
|
||||
* is actually drawn.
|
||||
*/
|
||||
export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--surface-foreground)',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
// The content box must cover the whole editor, not just the text, so
|
||||
// clicking the empty space below the last line still lands in it.
|
||||
minHeight: '100%',
|
||||
},
|
||||
// The caret is NOT the native one. `drawSelection()` hides that with
|
||||
// `caret-color: transparent !important` at the highest precedence and
|
||||
// draws its own `.cm-cursor` element, whose base style is a hard-coded
|
||||
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
|
||||
// does nothing at all — the border is what has to be coloured.
|
||||
//
|
||||
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
|
||||
// which needs the theme to declare itself dark. OpenChamber themes are not
|
||||
// only light or dark, so the cursor takes the surface foreground directly
|
||||
// instead. `&.cm-editor` matches the specificity of that `&dark` rule, and
|
||||
// theme styles mount after the base theme, so this wins in every variant.
|
||||
//
|
||||
// The `&light` / `&dark` scopes are NOT usable here: EditorView.theme
|
||||
// builds its selectors without scopes and throws RangeError on them the
|
||||
// moment this module is imported.
|
||||
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--surface-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
// Kebab-case: the theme emits `--surface-muted-foreground`. A camelCased
|
||||
// name here is not a missing colour but an invalid declaration, and since
|
||||
// `color` inherits, the placeholder silently renders at full text
|
||||
// brightness instead.
|
||||
'.cm-placeholder': { color: 'var(--surface-muted-foreground)' },
|
||||
// `drawSelection()` paints its own selection layer, and CodeMirror styles
|
||||
// it for the focused editor through
|
||||
// `&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground`
|
||||
// — six classes deep, so anything shorter loses and the selection comes out
|
||||
// in CodeMirror's stock lavender. Both rules below match the shape of the
|
||||
// ones they replace: unfocused first, then the focused case.
|
||||
//
|
||||
// The tint is translucent on purpose. An opaque selection would bury the
|
||||
// token colours the composer exists to show; the point of selecting text
|
||||
// here is to move it, not to stop reading it.
|
||||
'&.cm-editor .cm-selectionBackground, & .cm-selectionBackground': {
|
||||
background: 'color-mix(in srgb, var(--interactive-selection) 45%, transparent)',
|
||||
},
|
||||
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
|
||||
},
|
||||
// The native selection still shows through in places CodeMirror does not
|
||||
// draw over, such as the placeholder. Same colour as the native-selection
|
||||
// theme below, for the same reason: the selection token carries its own
|
||||
// alpha and reads as nearly invisible when mixed down again.
|
||||
'& ::selection': {
|
||||
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device keeps `drawSelection()` but shows the NATIVE selection through
|
||||
* it, for two independent reasons:
|
||||
*
|
||||
* - iOS attaches its selection handles (the draggable pins after a
|
||||
* double-tap) to the *visible* native selection, and `drawSelection()`
|
||||
* hides it with `.cm-line ::selection { background: transparent
|
||||
* !important }`, so the handles never appear and range selection is
|
||||
* undiscoverable.
|
||||
* - The painted selection layer sits *behind* the content, so any token with
|
||||
* its own background — inline code, code fences — covers it completely and
|
||||
* the selection is invisible inside those spans. The native selection
|
||||
* paints over element backgrounds.
|
||||
*
|
||||
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
|
||||
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text —
|
||||
* programmatic selection moves that iOS answers with severe input lag (each
|
||||
* one also resets the keyboard's autocorrect context). Typing must stay on
|
||||
* the drawn-selection code path; only the paint changes.
|
||||
*
|
||||
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
|
||||
* they carry `!important` and one class more specificity
|
||||
* (`.cm-content .cm-line` vs its `.cm-line`) to win regardless of style
|
||||
* mount order. The painted selection layer is hidden rather than removed —
|
||||
* two highlights would otherwise stack.
|
||||
*/
|
||||
export const NATIVE_SELECTION_THEME_SPEC = {
|
||||
// Built from `--primary`, not `--interactive-selection`: themes define the
|
||||
// selection token with its own alpha (often under 10%), so mixing it with
|
||||
// transparent again leaves the highlight barely perceptible. `--primary`
|
||||
// is a full-strength colour in every theme; a low mix of it reads as a
|
||||
// classic editor selection while the token colours stay legible through it.
|
||||
'& .cm-content .cm-line ::selection, & .cm-content .cm-line::selection': {
|
||||
backgroundColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent) !important',
|
||||
},
|
||||
// iOS derives the colour of its selection UI — the drag handles included —
|
||||
// from the caret colour, and `drawSelection()` sets `caret-color:
|
||||
// transparent !important` on both `.cm-content` and `.cm-line`. A visible
|
||||
// native selection alone is therefore not enough: the handles get drawn,
|
||||
// in transparent.
|
||||
//
|
||||
// But a visible native caret is not free either: while it shows, WebKit
|
||||
// re-renders its caret UI after every keystroke's decoration redraw, which
|
||||
// arrives as severe input lag. The handles only exist while a RANGE is
|
||||
// selected — exactly when there is no caret — so the native caret (and the
|
||||
// drawn cursor layer's absence) are scoped to `.oc-native-range`, which
|
||||
// `composerNativeSelectionExtension` sets on the editor whenever the main
|
||||
// selection is non-empty. Typing stays on the transparent-native-caret
|
||||
// fast path.
|
||||
'&.cm-editor.oc-native-range .cm-content, &.cm-editor.oc-native-range .cm-content .cm-line': {
|
||||
caretColor: 'var(--surface-foreground) !important',
|
||||
},
|
||||
'&.oc-native-range .cm-scroller > .cm-cursorLayer': {
|
||||
display: 'none',
|
||||
},
|
||||
// The layers live beside the content, as children of the scroller.
|
||||
'& .cm-scroller > .cm-selectionLayer': {
|
||||
display: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* The native-selection arrangement, installed on every device: the theme
|
||||
* above plus the `.oc-native-range` marker class that scopes its caret rules
|
||||
* to the moments a range is actually selected. `editorAttributes`
|
||||
* re-evaluates on every update, so the class follows the selection with no
|
||||
* listener of its own.
|
||||
*/
|
||||
export const composerNativeSelectionExtension = [
|
||||
composerNativeSelectionTheme,
|
||||
EditorView.editorAttributes.of((view) =>
|
||||
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Somewhere to keep a composer editor alive across an unmount.
|
||||
*
|
||||
* The mobile composer swaps between a collapsed pill and the full composer,
|
||||
* which are different subtrees — so the editor unmounts and mounts again on
|
||||
* every keyboard toggle. With a textarea that cost nothing. Building a
|
||||
* CodeMirror view is not nothing: extensions, state, document, decorations and
|
||||
* a first measure, all inside the tap's `flushSync`, before the browser is
|
||||
* allowed to paint the swap. That is enough to push the visible transformation
|
||||
* past the keyboard animation, which is what it looked like from the outside.
|
||||
*
|
||||
* Handing the editor a store owned by something longer-lived lets the view be
|
||||
* detached and re-attached instead of destroyed and rebuilt. Whoever creates
|
||||
* the store owns the view's lifetime and must destroy it.
|
||||
*/
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { ComposerEditorProps } from './ComposerEditor';
|
||||
|
||||
export interface ComposerEditorViewStore {
|
||||
view: EditorView | null;
|
||||
/**
|
||||
* Where the view's extensions read their callbacks from. It lives here
|
||||
* rather than in the component so a kept-alive view keeps calling into
|
||||
* whichever instance is currently mounted, never a dead one.
|
||||
*/
|
||||
handlers: { current: ComposerEditorProps } | null;
|
||||
}
|
||||
|
||||
export function createComposerEditorViewStore(): ComposerEditorViewStore {
|
||||
return { view: null, handlers: null };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
classifyMention,
|
||||
cleanMentionName,
|
||||
isMentionBoundary,
|
||||
looksLikeFilePath,
|
||||
scanMentions,
|
||||
} from '../mentions';
|
||||
|
||||
const names = (text: string) => scanMentions(text).map((token) => token.name);
|
||||
const raws = (text: string) => scanMentions(text).map((token) => token.raw);
|
||||
|
||||
describe('scanMentions — boundaries', () => {
|
||||
test('a mention at the start of the text', () => {
|
||||
expect(names('@build do this')).toEqual(['build']);
|
||||
});
|
||||
|
||||
test('a mention after whitespace', () => {
|
||||
expect(names('ask @build about it')).toEqual(['build']);
|
||||
});
|
||||
|
||||
test('an email address is not a mention', () => {
|
||||
expect(names('write to me@example.com')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a scoped package is not a mention', () => {
|
||||
expect(names('install @scope/pkg')).toEqual(['scope/pkg']);
|
||||
expect(names('bump foo@scope/pkg')).toEqual([]);
|
||||
});
|
||||
|
||||
test('opening punctuation still starts a mention', () => {
|
||||
expect(names('(@build) [@plan] {@x.ts} "@y.ts"')).toEqual(['build', 'plan', 'x.ts', 'y.ts']);
|
||||
});
|
||||
|
||||
test('multiple mentions on one line', () => {
|
||||
expect(names('@a.ts and @b.ts')).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
test('mentions across lines', () => {
|
||||
expect(names('@a.ts\n@b.ts')).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
test('a bare @ is not a mention', () => {
|
||||
expect(names('call me @ noon')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a token that cleans away to nothing is skipped', () => {
|
||||
expect(names('@... and @`')).toEqual([]);
|
||||
});
|
||||
|
||||
test('text without @ scans to nothing', () => {
|
||||
expect(scanMentions('plain text')).toEqual([]);
|
||||
expect(scanMentions('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('isMentionBoundary agrees with the scanner', () => {
|
||||
expect(isMentionBoundary('@a', 0)).toBe(true);
|
||||
expect(isMentionBoundary('x @a', 2)).toBe(true);
|
||||
expect(isMentionBoundary('x@a', 1)).toBe(false);
|
||||
expect(isMentionBoundary('1@a', 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanMentions — name cleanup', () => {
|
||||
test('trailing sentence punctuation is not part of the name', () => {
|
||||
expect(names('see @a/b.ts, then @c/d.ts.')).toEqual(['a/b.ts', 'c/d.ts']);
|
||||
expect(names('@x.ts! @y.ts? @z.ts;')).toEqual(['x.ts', 'y.ts', 'z.ts']);
|
||||
});
|
||||
|
||||
test('wrapping quotes and brackets are stripped from both ends', () => {
|
||||
expect(names('`@a/b.ts`')).toEqual(['a/b.ts']);
|
||||
expect(names('(@a/b.ts)')).toEqual(['a/b.ts']);
|
||||
expect(names('<@a/b.ts>')).toEqual(['a/b.ts']);
|
||||
});
|
||||
|
||||
test('the raw token still covers the punctuation the name dropped', () => {
|
||||
expect(raws('see @a/b.ts, ok')).toEqual(['@a/b.ts,']);
|
||||
});
|
||||
|
||||
test('the reference span excludes brushing punctuation', () => {
|
||||
const text = 'see @a/b.ts, ok';
|
||||
const [token] = scanMentions(text);
|
||||
expect(text.slice(token.start, token.end)).toBe('@a/b.ts');
|
||||
});
|
||||
|
||||
test('leading noise shifts the reference span past it', () => {
|
||||
const text = '`@a/b.ts`';
|
||||
const [token] = scanMentions(text);
|
||||
expect(text.slice(token.start, token.end)).toBe('@a/b.ts');
|
||||
});
|
||||
|
||||
test('a trailing slash is kept — directories are mentionable', () => {
|
||||
expect(names('@src/components/')).toEqual(['src/components/']);
|
||||
});
|
||||
|
||||
test('cleanMentionName is idempotent', () => {
|
||||
expect(cleanMentionName(cleanMentionName('`a/b.ts`,'))).toBe('a/b.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanMentions — offsets', () => {
|
||||
test('a clean token has identical reference and raw spans', () => {
|
||||
const text = 'ask @build now';
|
||||
const [token] = scanMentions(text);
|
||||
expect(text.slice(token.start, token.end)).toBe(token.raw);
|
||||
expect(token.start).toBe(4);
|
||||
expect(token.end).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyMention', () => {
|
||||
const classifier = {
|
||||
knownAgentNames: new Set(['build', 'plan']),
|
||||
confirmedMentions: new Set(['NOTES']),
|
||||
};
|
||||
|
||||
test('a known agent name classifies as an agent', () => {
|
||||
expect(classifyMention('build', classifier)).toBe('agent');
|
||||
});
|
||||
|
||||
test('agent matching is case-insensitive', () => {
|
||||
expect(classifyMention('Build', classifier)).toBe('agent');
|
||||
});
|
||||
|
||||
test('a path-like name classifies as a file', () => {
|
||||
expect(classifyMention('src/app.ts', classifier)).toBe('file');
|
||||
expect(classifyMention('README.md', classifier)).toBe('file');
|
||||
expect(classifyMention('win\\path', classifier)).toBe('file');
|
||||
});
|
||||
|
||||
test('a picker-confirmed extensionless name classifies as a file', () => {
|
||||
expect(classifyMention('NOTES', classifier)).toBe('file');
|
||||
});
|
||||
|
||||
test('an unknown bare word classifies as nothing', () => {
|
||||
expect(classifyMention('nothing', classifier)).toBeNull();
|
||||
expect(classifyMention('', classifier)).toBeNull();
|
||||
});
|
||||
|
||||
test('HTML fragments do not classify as file references', () => {
|
||||
expect(classifyMention('import</style>', classifier)).toBeNull();
|
||||
expect(classifyMention('src/<style.css', classifier)).toBeNull();
|
||||
});
|
||||
|
||||
test('an agent name wins over a file-looking name', () => {
|
||||
const shadowed = {
|
||||
knownAgentNames: new Set(['a.ts']),
|
||||
confirmedMentions: new Set<string>(),
|
||||
};
|
||||
expect(classifyMention('a.ts', shadowed)).toBe('agent');
|
||||
});
|
||||
|
||||
test('looksLikeFilePath is independent of the agent list', () => {
|
||||
expect(looksLikeFilePath('a/b', new Set())).toBe(true);
|
||||
expect(looksLikeFilePath('plain', new Set())).toBe(false);
|
||||
expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { pathHighlightRanges, scanPaths } from '../paths';
|
||||
|
||||
const paths = (text: string) => scanPaths(text).map((token) => token.path);
|
||||
|
||||
describe('scanPaths — what counts as a path', () => {
|
||||
test('a home-relative path', () => {
|
||||
expect(paths('see ~/repos/ocb/README.md')).toEqual(['/repos/ocb/README.md']);
|
||||
});
|
||||
|
||||
test('a project-relative path', () => {
|
||||
expect(paths('open ~src/components/App.tsx')).toEqual(['src/components/App.tsx']);
|
||||
});
|
||||
|
||||
test('a bare filename with an extension', () => {
|
||||
expect(paths('edit ~README.md')).toEqual(['README.md']);
|
||||
});
|
||||
|
||||
test('a windows path', () => {
|
||||
expect(paths('at ~C:\\repo\\a.ts')).toEqual(['C:\\repo\\a.ts']);
|
||||
});
|
||||
|
||||
test('a word without a separator or extension is prose', () => {
|
||||
expect(paths('it took ~approximately an hour')).toEqual([]);
|
||||
expect(paths('~ish')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an approximate number is prose', () => {
|
||||
expect(paths('about ~500 items')).toEqual([]);
|
||||
});
|
||||
|
||||
test('an approximate decimal is prose, not a file', () => {
|
||||
// `~` also reads as "about"; an extension must start with a letter.
|
||||
expect(paths('roughly ~1.2 seconds')).toEqual([]);
|
||||
expect(paths('~0.5x slower')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanPaths — boundaries', () => {
|
||||
test('a path at the start of the text', () => {
|
||||
expect(paths('~src/a.ts is the file')).toEqual(['src/a.ts']);
|
||||
});
|
||||
|
||||
test('a tilde inside a word is not a path', () => {
|
||||
expect(paths('foo~bar/baz.ts')).toEqual([]);
|
||||
});
|
||||
|
||||
test('brackets and quotes still open a path', () => {
|
||||
expect(paths('(~src/a.ts) "~b/c.ts"')).toEqual(['src/a.ts', 'b/c.ts']);
|
||||
});
|
||||
|
||||
test('several paths on one line', () => {
|
||||
expect(paths('~a/b.ts and ~c/d.ts')).toEqual(['a/b.ts', 'c/d.ts']);
|
||||
});
|
||||
|
||||
test('trailing sentence punctuation is not part of the path', () => {
|
||||
expect(paths('see ~src/a.ts, then stop.')).toEqual(['src/a.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanPaths — not confused with other tilde syntax', () => {
|
||||
test('a tilde code fence is left alone', () => {
|
||||
expect(paths('~~~\nbody\n~~~')).toEqual([]);
|
||||
});
|
||||
|
||||
test('strikethrough is left alone', () => {
|
||||
expect(paths('~~struck out~~')).toEqual([]);
|
||||
});
|
||||
|
||||
test('text without a tilde scans to nothing', () => {
|
||||
expect(scanPaths('plain text')).toEqual([]);
|
||||
expect(scanPaths('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathHighlightRanges', () => {
|
||||
test('the range covers the tilde and the path', () => {
|
||||
const text = 'see ~src/a.ts here';
|
||||
const [range] = pathHighlightRanges(text);
|
||||
expect(text.slice(range.start, range.end)).toBe('~src/a.ts');
|
||||
expect(range.style).toBe('path');
|
||||
});
|
||||
|
||||
test('prose produces no ranges', () => {
|
||||
expect(pathHighlightRanges('nothing here')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
collectKnownTokenNames,
|
||||
filterKnownTokens,
|
||||
scanPrefixTokens,
|
||||
} from '../prefixTokens';
|
||||
|
||||
const slashNames = (text: string) => scanPrefixTokens(text, '/').map((token) => token.name);
|
||||
const hashNames = (text: string) => scanPrefixTokens(text, '#').map((token) => token.name);
|
||||
|
||||
describe('scanPrefixTokens — boundaries', () => {
|
||||
test('a token at the start of the text', () => {
|
||||
expect(slashNames('/review this')).toEqual(['review']);
|
||||
expect(hashNames('#note here')).toEqual(['note']);
|
||||
});
|
||||
|
||||
test('a token after whitespace, mid-sentence', () => {
|
||||
expect(slashNames('please run /explore now')).toEqual(['explore']);
|
||||
expect(hashNames('use #sig at the end')).toEqual(['sig']);
|
||||
});
|
||||
|
||||
test('a token after a newline', () => {
|
||||
expect(slashNames('line one\n/review')).toEqual(['review']);
|
||||
});
|
||||
|
||||
test('a path segment is not a slash token', () => {
|
||||
expect(slashNames('src/components/App.tsx')).toEqual([]);
|
||||
expect(slashNames('see a/b')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a fragment or issue reference is not a snippet token', () => {
|
||||
expect(hashNames('issue#42')).toEqual([]);
|
||||
expect(hashNames('page.html#anchor')).toEqual([]);
|
||||
});
|
||||
|
||||
test('multiple tokens on one line', () => {
|
||||
expect(slashNames('/plan then /review')).toEqual(['plan', 'review']);
|
||||
});
|
||||
|
||||
test('a name must start with an alphanumeric', () => {
|
||||
expect(slashNames('/-dash')).toEqual([]);
|
||||
expect(slashNames('/_under')).toEqual([]);
|
||||
expect(hashNames('#-x')).toEqual([]);
|
||||
});
|
||||
|
||||
test('names may contain dashes, underscores and digits', () => {
|
||||
expect(slashNames('/workspace-review /My_Skill /a1')).toEqual([
|
||||
'workspace-review',
|
||||
'My_Skill',
|
||||
'a1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a bare sigil is not a token', () => {
|
||||
expect(slashNames('a / b')).toEqual([]);
|
||||
expect(hashNames('a # b')).toEqual([]);
|
||||
});
|
||||
|
||||
test('text without the sigil scans to nothing', () => {
|
||||
expect(scanPrefixTokens('nothing here', '/')).toEqual([]);
|
||||
expect(scanPrefixTokens('', '#')).toEqual([]);
|
||||
});
|
||||
|
||||
test('the two sigils do not see each other', () => {
|
||||
expect(slashNames('#snippet')).toEqual([]);
|
||||
expect(hashNames('/skill')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanPrefixTokens — offsets', () => {
|
||||
test('start and end delimit the sigil plus the name', () => {
|
||||
const text = 'run /review now';
|
||||
const [token] = scanPrefixTokens(text, '/');
|
||||
expect(text.slice(token.start, token.end)).toBe('/review');
|
||||
expect(token.prefix).toBe('/');
|
||||
});
|
||||
|
||||
test('the boundary whitespace is not part of the token', () => {
|
||||
const [token] = scanPrefixTokens(' /plan', '/');
|
||||
expect(token.start).toBe(2);
|
||||
});
|
||||
|
||||
test('adjacent tokens keep independent offsets', () => {
|
||||
const text = '/a /b';
|
||||
const tokens = scanPrefixTokens(text, '/');
|
||||
expect(tokens.map((token) => text.slice(token.start, token.end))).toEqual(['/a', '/b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterKnownTokens', () => {
|
||||
const tokens = scanPrefixTokens('/Review /unknown /plan', '/');
|
||||
|
||||
test('keeps only tokens present in the known set', () => {
|
||||
expect(filterKnownTokens(tokens, new Set(['review', 'plan'])).map((t) => t.name))
|
||||
.toEqual(['Review', 'plan']);
|
||||
});
|
||||
|
||||
test('case-insensitive is the default comparison', () => {
|
||||
expect(filterKnownTokens(tokens, new Set(['review'])).map((t) => t.name))
|
||||
.toEqual(['Review']);
|
||||
});
|
||||
|
||||
test('exact comparison respects the registered casing', () => {
|
||||
expect(filterKnownTokens(tokens, new Set(['review']), 'exact')).toEqual([]);
|
||||
expect(filterKnownTokens(tokens, new Set(['Review']), 'exact').map((t) => t.name))
|
||||
.toEqual(['Review']);
|
||||
});
|
||||
|
||||
test('an empty known set matches nothing', () => {
|
||||
expect(filterKnownTokens(tokens, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectKnownTokenNames', () => {
|
||||
test('returns distinct names in first-occurrence order', () => {
|
||||
expect(collectKnownTokenNames(
|
||||
'/plan and /review and /plan again',
|
||||
'/',
|
||||
new Set(['plan', 'review']),
|
||||
)).toEqual(['plan', 'review']);
|
||||
});
|
||||
|
||||
test('unknown tokens are dropped', () => {
|
||||
expect(collectKnownTokenNames('/plan /nope', '/', new Set(['plan'])))
|
||||
.toEqual(['plan']);
|
||||
});
|
||||
|
||||
test('exact comparison is available for registry-cased names', () => {
|
||||
expect(collectKnownTokenNames('/Deploy', '/', new Set(['Deploy']), 'exact'))
|
||||
.toEqual(['Deploy']);
|
||||
expect(collectKnownTokenNames('/deploy', '/', new Set(['Deploy']), 'exact'))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test('no tokens yields an empty list', () => {
|
||||
expect(collectKnownTokenNames('plain text', '/', new Set(['plan']))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildHighlightParts } from '../../../composerHighlight';
|
||||
import {
|
||||
tokenizeComposer,
|
||||
tokenizeMentions,
|
||||
type ComposerLanguageContext,
|
||||
} from '../tokenize';
|
||||
|
||||
const context = (overrides: Partial<ComposerLanguageContext> = {}): ComposerLanguageContext => ({
|
||||
inputMode: 'normal',
|
||||
knownAgentNames: new Set(['build', 'plan']),
|
||||
confirmedMentions: new Set(['NOTES']),
|
||||
knownSlashNames: new Set(['review', 'explore']),
|
||||
knownSnippetTriggers: new Set(['sig']),
|
||||
attachmentFilenames: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** The substring and style of every range, sorted for stable comparison. */
|
||||
const styled = (text: string, ctx = context()) =>
|
||||
tokenizeComposer(text, ctx)
|
||||
.map((range) => [text.slice(range.start, range.end), range.style] as const)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
|
||||
|
||||
const stylesOf = (text: string, ctx = context()) =>
|
||||
new Set(tokenizeComposer(text, ctx).map((range) => range.style));
|
||||
|
||||
describe('tokenizeComposer — reference constructs', () => {
|
||||
test('a known agent mention is styled as an agent', () => {
|
||||
expect(styled('ask @build please')).toEqual([['@build', 'mentionAgent']]);
|
||||
});
|
||||
|
||||
test('a path mention is styled as a file', () => {
|
||||
expect(styled('see @src/app.ts')).toEqual([['@src/app.ts', 'mentionFile']]);
|
||||
});
|
||||
|
||||
test('an unknown bare mention is not tokenized', () => {
|
||||
expect(styled('hi @stranger')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a known slash token is styled as a command', () => {
|
||||
expect(styled('run /review now')).toEqual([['/review', 'mentionCommand']]);
|
||||
});
|
||||
|
||||
test('an unknown slash token stays plain prose', () => {
|
||||
expect(styled('run /nosuchthing now')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a known snippet trigger is styled as a snippet', () => {
|
||||
expect(styled('end with #sig')).toEqual([['#sig', 'mentionSnippet']]);
|
||||
});
|
||||
|
||||
test('an attachment citation is styled as a file', () => {
|
||||
expect(styled('here [shot.png]', context({ attachmentFilenames: ['shot.png'] })))
|
||||
.toEqual([['[shot.png]', 'mentionFile']]);
|
||||
});
|
||||
|
||||
test('a citation for a file that is not attached is not tokenized', () => {
|
||||
expect(styled('here [other.png]', context({ attachmentFilenames: ['shot.png'] })))
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeComposer — a path is highlighted but never attached', () => {
|
||||
test('a ~path is styled', () => {
|
||||
expect(styled('see ~src/app.ts')).toEqual([['~src/app.ts', 'path']]);
|
||||
});
|
||||
|
||||
test('it needs no registry and no confirmation, unlike @', () => {
|
||||
// The same path behind `@` only resolves because it looks like a path;
|
||||
// behind `~` it is inert either way.
|
||||
const empty = context({ knownAgentNames: new Set(), confirmedMentions: new Set() });
|
||||
expect(styled('~docs/NOTES', empty)).toEqual([['~docs/NOTES', 'path']]);
|
||||
});
|
||||
|
||||
test('an @mention covering the same text keeps its own style', () => {
|
||||
expect(styled('@src/app.ts')).toEqual([['@src/app.ts', 'mentionFile']]);
|
||||
});
|
||||
|
||||
test('both forms can appear in one message', () => {
|
||||
expect(stylesOf('attach @a/b.ts but only mention ~c/d.ts'))
|
||||
.toEqual(new Set(['mentionFile', 'path']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeComposer — emphasis and attention', () => {
|
||||
test('emphasis is tokenized', () => {
|
||||
expect(stylesOf('**bold** and *slanted*'))
|
||||
.toEqual(new Set(['marker', 'strong', 'emphasis']));
|
||||
});
|
||||
|
||||
test('an attention line is tokenized', () => {
|
||||
expect(stylesOf('!!! read this')).toEqual(new Set(['marker', 'attention']));
|
||||
});
|
||||
|
||||
test('shell mode still disables everything', () => {
|
||||
expect(tokenizeComposer('**bold** ~a/b.ts !!! x', context({ inputMode: 'shell' })))
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeComposer — markdown still applies', () => {
|
||||
test('markdown and references coexist in one pass', () => {
|
||||
expect(stylesOf('# Title\n- see @src/app.ts and /review'))
|
||||
.toEqual(new Set(['marker', 'heading', 'listMarker', 'mentionFile', 'mentionCommand']));
|
||||
});
|
||||
|
||||
test('fenced code is tokenized', () => {
|
||||
expect(stylesOf('```\nplain\n```').has('codeFence')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeComposer — disabled and empty paths', () => {
|
||||
test('shell mode tokenizes nothing', () => {
|
||||
expect(tokenizeComposer('@build /review #sig', context({ inputMode: 'shell' })))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test('empty text tokenizes nothing', () => {
|
||||
expect(tokenizeComposer('', context())).toEqual([]);
|
||||
});
|
||||
|
||||
test('empty registries leave their sigils plain', () => {
|
||||
expect(styled('/review #sig', context({
|
||||
knownSlashNames: new Set(),
|
||||
knownSnippetTriggers: new Set(),
|
||||
}))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeComposer — ranges are usable', () => {
|
||||
test('every range indexes real text', () => {
|
||||
const text = '# H\n@src/a.ts /review #sig `code` [x.png]';
|
||||
const ctx = context({ attachmentFilenames: ['x.png'] });
|
||||
for (const range of tokenizeComposer(text, ctx)) {
|
||||
expect(range.start >= 0).toBe(true);
|
||||
expect(range.end <= text.length).toBe(true);
|
||||
expect(range.end > range.start).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the ranges reconstruct the text exactly through buildHighlightParts', () => {
|
||||
const text = 'ping @build about /review\n> quoted `code`';
|
||||
const parts = buildHighlightParts(text, tokenizeComposer(text, context()));
|
||||
expect(parts).not.toBeNull();
|
||||
expect(parts!.map((part) => part.text).join('')).toBe(text);
|
||||
});
|
||||
|
||||
test('a mention inside inline code keeps the mention style', () => {
|
||||
// Mentions outrank code in the priority table, so a referenced path
|
||||
// stays recognizable even when the user wrapped it in backticks.
|
||||
const text = '`@src/app.ts`';
|
||||
const parts = buildHighlightParts(text, tokenizeComposer(text, context()));
|
||||
expect(parts!.map((part) => part.text)).toEqual(['`', '@src/app.ts', '`']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeMentions', () => {
|
||||
test('classifies agents and files, skipping unknown words', () => {
|
||||
expect(tokenizeMentions('@build @src/a.ts @nobody', {
|
||||
knownAgentNames: new Set(['build']),
|
||||
confirmedMentions: new Set(),
|
||||
})).toEqual([
|
||||
{ start: 0, end: 6, kind: 'agent' },
|
||||
{ start: 7, end: 16, kind: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a picker-confirmed extensionless name counts as a file', () => {
|
||||
expect(tokenizeMentions('@NOTES', {
|
||||
knownAgentNames: new Set(),
|
||||
confirmedMentions: new Set(['NOTES']),
|
||||
})).toEqual([{ start: 0, end: 6, kind: 'file' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAutocompleteTrigger, type TriggerContext } from '../triggers';
|
||||
|
||||
const normal: TriggerContext = { inputMode: 'normal' };
|
||||
|
||||
/** Resolve with the caret placed at the `|` marker in `text`. */
|
||||
const at = (text: string, context: TriggerContext = normal) => {
|
||||
const cursor = text.indexOf('|');
|
||||
if (cursor === -1) throw new Error('caret marker `|` missing');
|
||||
return resolveAutocompleteTrigger(text.replace('|', ''), cursor, context);
|
||||
};
|
||||
|
||||
describe('command palette', () => {
|
||||
test('a leading slash opens the command palette', () => {
|
||||
expect(at('/rev|')).toEqual({ kind: 'command', query: 'rev' });
|
||||
});
|
||||
|
||||
test('a bare leading slash opens it with an empty query', () => {
|
||||
expect(at('/|')).toEqual({ kind: 'command', query: '' });
|
||||
});
|
||||
|
||||
test('a space anywhere turns it into an invocation, not a search', () => {
|
||||
expect(at('/review |')?.kind).not.toBe('command');
|
||||
expect(at('/rev|iew now')?.kind).not.toBe('command');
|
||||
});
|
||||
|
||||
test('the caret must stay inside the command word', () => {
|
||||
expect(at('/review\nnext line|')?.kind).not.toBe('command');
|
||||
});
|
||||
|
||||
test('a slash that is not in the first column is not the palette', () => {
|
||||
expect(at(' /rev|')).toEqual({ kind: 'skill', query: 'rev' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline skill picker', () => {
|
||||
test('a slash after whitespace opens the skill picker', () => {
|
||||
expect(at('please run /explo|')).toEqual({ kind: 'skill', query: 'explo' });
|
||||
});
|
||||
|
||||
test('a slash after a newline opens it', () => {
|
||||
expect(at('line\n/pl|')).toEqual({ kind: 'skill', query: 'pl' });
|
||||
});
|
||||
|
||||
test('a path separator does not open it', () => {
|
||||
expect(at('src/comp|')).toBeNull();
|
||||
});
|
||||
|
||||
test('a space after the sigil closes it', () => {
|
||||
expect(at('run /explore |')).toBeNull();
|
||||
});
|
||||
|
||||
test('the nearest slash before the caret wins', () => {
|
||||
expect(at('/a b /c|')).toEqual({ kind: 'skill', query: 'c' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippet picker', () => {
|
||||
test('a hash after whitespace opens the snippet picker', () => {
|
||||
expect(at('use #sig|')).toEqual({ kind: 'snippet', query: 'sig' });
|
||||
});
|
||||
|
||||
test('a hash at the start of the text opens it', () => {
|
||||
expect(at('#sig|')).toEqual({ kind: 'snippet', query: 'sig' });
|
||||
});
|
||||
|
||||
test('an issue reference does not open it', () => {
|
||||
expect(at('issue#42|')).toBeNull();
|
||||
});
|
||||
|
||||
test('a slash outranks a hash when both are candidates', () => {
|
||||
expect(at('#tag /skill|')).toEqual({ kind: 'skill', query: 'skill' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mention picker', () => {
|
||||
test('an at-sign after whitespace opens the mention picker', () => {
|
||||
expect(at('see @src/ap|')).toEqual({ kind: 'mention', query: 'src/ap' });
|
||||
});
|
||||
|
||||
test('a bare at-sign opens it with an empty query', () => {
|
||||
expect(at('@|')).toEqual({ kind: 'mention', query: '' });
|
||||
});
|
||||
|
||||
test('an email address does not open it', () => {
|
||||
expect(at('me@example|')).toBeNull();
|
||||
});
|
||||
|
||||
test('a space after the sigil closes it', () => {
|
||||
expect(at('@build now|')).toBeNull();
|
||||
});
|
||||
|
||||
test('a pasted at-sign does not open the picker', () => {
|
||||
expect(at('@src/app.ts|', {
|
||||
inputMode: 'normal',
|
||||
inputSource: 'paste',
|
||||
insertedText: '@src/app.ts',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('a paste without an at-sign still resolves normally', () => {
|
||||
expect(at('@src|', {
|
||||
inputMode: 'normal',
|
||||
inputSource: 'paste',
|
||||
insertedText: 'src',
|
||||
})).toEqual({ kind: 'mention', query: 'src' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence and disabling', () => {
|
||||
test('shell mode disables every picker', () => {
|
||||
const shell: TriggerContext = { inputMode: 'shell' };
|
||||
expect(at('/rev|', shell)).toBeNull();
|
||||
expect(at('@src|', shell)).toBeNull();
|
||||
expect(at('#sig|', shell)).toBeNull();
|
||||
});
|
||||
|
||||
test('the command palette outranks the inline skill picker', () => {
|
||||
expect(at('/pl|')).toEqual({ kind: 'command', query: 'pl' });
|
||||
});
|
||||
|
||||
test('plain prose triggers nothing', () => {
|
||||
expect(at('just typing a sentence|')).toBeNull();
|
||||
expect(at('|')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The composer's `@mention` grammar — the single source of truth for what an
|
||||
* `@token` means.
|
||||
*
|
||||
* Before this module the same rule was re-implemented four times inside
|
||||
* ChatInput.tsx with subtly different cleanup (highlighting, send-time
|
||||
* extraction, deletion, and the autocomplete trigger). Each new reference
|
||||
* type had to be taught to all four. Everything
|
||||
* that needs to know where mentions are now scans with `scanMentions` and
|
||||
* decides what they are with `classifyMention`.
|
||||
*
|
||||
* A mention is `@` at a token boundary followed by non-whitespace. The visible
|
||||
* span (`start`..`end`) covers the raw token including any punctuation that
|
||||
* merely brushes against it; `name` is that token cleaned of wrapping
|
||||
* punctuation, and is what gets matched against agents and file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Characters that may sit directly before `@`. Anything else (a letter, digit
|
||||
* or `/`) means the `@` belongs to the preceding word — an email address, a
|
||||
* scoped npm package, a path segment — and is not a mention.
|
||||
*/
|
||||
const MENTION_BOUNDARY_BEFORE = /[\s()[\]{}<>"'`,.;:]/;
|
||||
|
||||
/**
|
||||
* Punctuation that commonly wraps a mention and is never part of the name.
|
||||
* The two sets are deliberately symmetric: every bracket accepted before `@`
|
||||
* is also stripped from the tail, so `[@plan]` and `(@plan)` both reference
|
||||
* `plan`. The pre-unification rules allowed `[`/`{` in front but only stripped
|
||||
* `)` behind, which left `@plan]` as the resolved name.
|
||||
*/
|
||||
const LEADING_NOISE = /^[`"'<([{]+/;
|
||||
const TRAILING_NOISE = /[)\]},.;:!?`"'>]+$/;
|
||||
|
||||
const MENTION_SCAN = /@([^\s]+)/g;
|
||||
|
||||
export interface MentionToken {
|
||||
/** Offset of the `@`. */
|
||||
start: number;
|
||||
/**
|
||||
* Offset just past the reference itself — the `@` plus the cleaned name.
|
||||
* This is the span to highlight: in `see @a/b.ts, ok` the comma is
|
||||
* punctuation of the sentence, not part of the file being referenced.
|
||||
*/
|
||||
end: number;
|
||||
/** The raw token including `@` and any brushing punctuation. */
|
||||
raw: string;
|
||||
/** The token with `@` and wrapping punctuation removed. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** True when `@` at `index` starts a mention rather than continuing a word. */
|
||||
export function isMentionBoundary(text: string, index: number): boolean {
|
||||
if (index <= 0) return true;
|
||||
return MENTION_BOUNDARY_BEFORE.test(text[index - 1]);
|
||||
}
|
||||
|
||||
/** Strip the punctuation that wraps a mention without belonging to it. */
|
||||
export function cleanMentionName(rawName: string): string {
|
||||
return rawName
|
||||
.trim()
|
||||
.replace(LEADING_NOISE, '')
|
||||
.replace(TRAILING_NOISE, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every `@mention` in `text`. Tokens whose name cleans away to nothing
|
||||
* (a bare `@`, `@...`) are skipped — there is nothing to reference.
|
||||
*/
|
||||
export function scanMentions(text: string): MentionToken[] {
|
||||
if (!text || !text.includes('@')) return [];
|
||||
|
||||
const tokens: MentionToken[] = [];
|
||||
MENTION_SCAN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = MENTION_SCAN.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
if (!isMentionBoundary(text, start)) continue;
|
||||
|
||||
const rawName = match[1] ?? '';
|
||||
const name = cleanMentionName(rawName);
|
||||
if (!name) continue;
|
||||
|
||||
// The cleaned name is a substring of the raw one, so its offset inside
|
||||
// the token is exactly how much leading noise was stripped.
|
||||
const nameStart = start + 1 + rawName.indexOf(name);
|
||||
|
||||
tokens.push({
|
||||
start,
|
||||
end: nameStart + name.length,
|
||||
raw: match[0],
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export type MentionKind = 'agent' | 'file';
|
||||
|
||||
export interface MentionClassifier {
|
||||
/** Lowercased names of the agents that can be mentioned. */
|
||||
knownAgentNames: ReadonlySet<string>;
|
||||
/** Mention paths confirmed by the picker, a drop, or a restored draft. */
|
||||
confirmedMentions: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name looks like a file when it carries path structure (a separator or an
|
||||
* extension) or when the user confirmed it explicitly through the picker.
|
||||
* Agents win over files: an agent name is an exact, known identifier.
|
||||
*/
|
||||
export function classifyMention(
|
||||
name: string,
|
||||
classifier: MentionClassifier,
|
||||
): MentionKind | null {
|
||||
if (!name) return null;
|
||||
// HTML fragments are prompt text, never references. In particular, do not
|
||||
// interpret CSS syntax such as `@import</style>` as a local file path.
|
||||
if (name.includes('<') || name.includes('>')) return null;
|
||||
if (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent';
|
||||
if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function looksLikeFilePath(
|
||||
name: string,
|
||||
confirmedMentions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return name.includes('/')
|
||||
|| name.includes('\\')
|
||||
|| name.includes('.')
|
||||
|| confirmedMentions.has(name);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `~path` — a path written for the reader, not for the machine.
|
||||
*
|
||||
* `@path` attaches a file: it resolves against the project, searches, and rides
|
||||
* along with the message. Often that is not what is wanted. Explaining where
|
||||
* something lives, or naming a file in another repository, should not silently
|
||||
* attach it — but it should still stand out from prose, because a path buried
|
||||
* in a sentence is hard to read.
|
||||
*
|
||||
* `~` marks exactly that: **highlighting without attachment**. It is inert by
|
||||
* design, so nothing here feeds the autocomplete or the send path.
|
||||
*
|
||||
* @see mentions.ts for `@`, which does attach.
|
||||
*/
|
||||
|
||||
import type { HighlightRange } from '../../composerHighlight';
|
||||
|
||||
/**
|
||||
* A path token: `~` followed by non-whitespace. The same trailing punctuation
|
||||
* rule as mentions applies, so `see ~src/app.ts,` marks the path and leaves
|
||||
* the comma to the sentence.
|
||||
*/
|
||||
const PATH_SCAN = /~([^\s~]+)/g;
|
||||
const TRAILING_NOISE = /[)\]},.;:!?`"'>]+$/;
|
||||
|
||||
/**
|
||||
* `~` opens a path only at a token boundary. Inside a word it is arithmetic,
|
||||
* an approximation, or part of an identifier.
|
||||
*/
|
||||
const BOUNDARY_BEFORE = /[\s()[\]{}<>"'`,;:]/;
|
||||
|
||||
export interface PathToken {
|
||||
/** Offset of the `~`. */
|
||||
start: number;
|
||||
/** Offset just past the path. */
|
||||
end: number;
|
||||
/** The path without its `~`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the token looks like a path rather than a stray word. A separator
|
||||
* or a file extension is required, so `~approximately` stays prose while
|
||||
* `~/repos/ocb` and `~README.md` are paths.
|
||||
*
|
||||
* The extension must begin with a letter. `~` also reads as "about" in front
|
||||
* of a number, and `~1.2 seconds` is far more likely to be prose than a file.
|
||||
*/
|
||||
function looksLikePath(value: string): boolean {
|
||||
return value.includes('/') || value.includes('\\') || /\.[A-Za-z]/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every `~path` in `text`.
|
||||
*
|
||||
* Deliberately blind to `~~strikethrough~~` and to `~~~` code fences: the scan
|
||||
* stops at `~`, so a doubled marker yields nothing to highlight and the fence
|
||||
* tokenizer keeps its own text.
|
||||
*/
|
||||
export function scanPaths(text: string): PathToken[] {
|
||||
if (!text || !text.includes('~')) return [];
|
||||
|
||||
const tokens: PathToken[] = [];
|
||||
PATH_SCAN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = PATH_SCAN.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const before = start > 0 ? text[start - 1] : '';
|
||||
if (before && !BOUNDARY_BEFORE.test(before)) continue;
|
||||
|
||||
const path = (match[1] ?? '').replace(TRAILING_NOISE, '');
|
||||
if (!path || !looksLikePath(path)) continue;
|
||||
|
||||
tokens.push({ start, end: start + 1 + path.length, path });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Path tokens as highlight ranges. */
|
||||
export function pathHighlightRanges(text: string): HighlightRange[] {
|
||||
return scanPaths(text).map((token) => ({
|
||||
start: token.start,
|
||||
end: token.end,
|
||||
style: 'path' as const,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The composer's prefix-token grammar: `/skill`, `/command` and `#snippet`.
|
||||
*
|
||||
* Structurally these are the same construct — a sigil at a word boundary
|
||||
* followed by an identifier — and they were previously scanned by three
|
||||
* different regexes per sigil (highlighting, send-time collection, and the
|
||||
* autocomplete trigger), each with its own idea of the valid character set.
|
||||
* The send-time skill scanner, for instance, accepted only lowercase names, so
|
||||
* a `/My_Skill` token was painted as a command but never collected.
|
||||
*
|
||||
* Scanning is deliberately generous: it finds every syntactically plausible
|
||||
* token and leaves the decision of what exists to the caller, which holds the
|
||||
* authoritative set of commands, skills or snippets. Membership is the
|
||||
* authority; the pattern is only a locator.
|
||||
*
|
||||
* @see mentions.ts for the `@` half of the grammar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Identifier body shared by all prefix tokens: starts alphanumeric, then
|
||||
* alphanumerics, `-` and `_`. Kept in one place so `/` and `#` cannot drift
|
||||
* apart again.
|
||||
*/
|
||||
const TOKEN_NAME = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
||||
|
||||
/** Sigils that introduce a prefix token. */
|
||||
export type TokenPrefix = '/' | '#';
|
||||
|
||||
export interface PrefixToken {
|
||||
/** Offset of the sigil. */
|
||||
start: number;
|
||||
/** Offset just past the identifier. */
|
||||
end: number;
|
||||
/** The sigil that introduced this token. */
|
||||
prefix: TokenPrefix;
|
||||
/** The identifier without its sigil. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SCANNERS: Record<TokenPrefix, RegExp> = {
|
||||
'/': new RegExp(`(^|\\s)\\/(${TOKEN_NAME})`, 'g'),
|
||||
'#': new RegExp(`(^|\\s)#(${TOKEN_NAME})`, 'g'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Find every `prefix`-token in `text`. A token must sit at the start of the
|
||||
* text or directly after whitespace, so `a/b` and `#1` inside `issue#1` stay
|
||||
* ordinary prose.
|
||||
*/
|
||||
export function scanPrefixTokens(text: string, prefix: TokenPrefix): PrefixToken[] {
|
||||
if (!text || !text.includes(prefix)) return [];
|
||||
|
||||
const scanner = SCANNERS[prefix];
|
||||
const tokens: PrefixToken[] = [];
|
||||
scanner.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = scanner.exec(text)) !== null) {
|
||||
const name = match[2];
|
||||
// The leading-whitespace capture keeps the boundary check inside the
|
||||
// pattern; the token itself starts after it.
|
||||
const start = match.index + match[1].length;
|
||||
tokens.push({ start, end: start + 1 + name.length, prefix, name });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens whose name is present in `known`, in document order. `compare`
|
||||
* decides how a token name is matched against the set — snippets and slash
|
||||
* invocations both match case-insensitively, while the skill-instruction
|
||||
* builder matches the exact registered name.
|
||||
*/
|
||||
export function filterKnownTokens(
|
||||
tokens: readonly PrefixToken[],
|
||||
known: ReadonlySet<string>,
|
||||
compare: 'exact' | 'case-insensitive' = 'case-insensitive',
|
||||
): PrefixToken[] {
|
||||
if (known.size === 0) return [];
|
||||
return tokens.filter((token) => known.has(
|
||||
compare === 'exact' ? token.name : token.name.toLowerCase(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct names of the known `prefix`-tokens in `text`, in first-occurrence
|
||||
* order. Used to tell the model which skills the user named explicitly.
|
||||
*/
|
||||
export function collectKnownTokenNames(
|
||||
text: string,
|
||||
prefix: TokenPrefix,
|
||||
known: ReadonlySet<string>,
|
||||
compare: 'exact' | 'case-insensitive' = 'case-insensitive',
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const token of filterKnownTokens(scanPrefixTokens(text, prefix), known, compare)) {
|
||||
if (seen.has(token.name)) continue;
|
||||
seen.add(token.name);
|
||||
names.push(token.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* One pass over the composer text producing every highlight range.
|
||||
*
|
||||
* The composer used to derive its highlighting from six independent memos in
|
||||
* ChatInput.tsx — markdown, fenced-code syntax, mentions, slash tokens,
|
||||
* snippet tokens and attachment citations — each re-scanning the same string
|
||||
* and each having to be remembered when a new construct was added. This is the
|
||||
* single entry point: give it the text and what the composer knows about the
|
||||
* workspace, get back the ranges.
|
||||
*
|
||||
* It is also the seam the editor renders through. The mirror overlay consumes
|
||||
* these ranges via `buildHighlightParts`; a CodeMirror view maps the same
|
||||
* ranges to decorations. Adding a construct to the language means adding it
|
||||
* here, once.
|
||||
*/
|
||||
|
||||
import { findAttachmentCitationRanges } from '../../attachmentCitations';
|
||||
import { highlightFencedCode } from '../../composerCodeHighlight';
|
||||
import {
|
||||
mentionRangesToHighlightRanges,
|
||||
tokenizeMarkdown,
|
||||
type HighlightRange,
|
||||
type MentionRange,
|
||||
} from '../../composerHighlight';
|
||||
import { classifyMention, scanMentions } from './mentions';
|
||||
import { pathHighlightRanges } from './paths';
|
||||
import { filterKnownTokens, scanPrefixTokens } from './prefixTokens';
|
||||
|
||||
/**
|
||||
* What the composer knows about its workspace while tokenizing. Every set is
|
||||
* authoritative: a token is only a reference if it resolves against one of
|
||||
* them, so unknown `/tokens` and `@words` stay plain prose.
|
||||
*/
|
||||
export interface ComposerLanguageContext {
|
||||
/** Shell mode (`!cmd`) is not the prompt language — nothing is tokenized. */
|
||||
inputMode: 'normal' | 'shell';
|
||||
/** Lowercased names of the agents that can be mentioned. */
|
||||
knownAgentNames: ReadonlySet<string>;
|
||||
/** Mention paths confirmed by the picker, a drop, or a restored draft. */
|
||||
confirmedMentions: ReadonlySet<string>;
|
||||
/** Lowercased command, skill and built-in names invocable with `/`. */
|
||||
knownSlashNames: ReadonlySet<string>;
|
||||
/** Lowercased snippet names and aliases invocable with `#`. */
|
||||
knownSnippetTriggers: ReadonlySet<string>;
|
||||
/** Filenames of the currently attached files, cited inline as `[name]`. */
|
||||
attachmentFilenames: readonly string[];
|
||||
}
|
||||
|
||||
/** Mention ranges alone — the composer also needs these to resolve references. */
|
||||
export function tokenizeMentions(
|
||||
text: string,
|
||||
context: Pick<ComposerLanguageContext, 'knownAgentNames' | 'confirmedMentions'>,
|
||||
): MentionRange[] {
|
||||
const ranges: MentionRange[] = [];
|
||||
for (const token of scanMentions(text)) {
|
||||
const kind = classifyMention(token.name, context);
|
||||
if (kind) ranges.push({ start: token.start, end: token.end, kind });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every highlight range in `text`. Ranges may overlap; `buildHighlightParts`
|
||||
* resolves them by priority.
|
||||
*/
|
||||
export function tokenizeComposer(
|
||||
text: string,
|
||||
context: ComposerLanguageContext,
|
||||
): HighlightRange[] {
|
||||
if (!text || context.inputMode === 'shell') return [];
|
||||
|
||||
const ranges: HighlightRange[] = [
|
||||
...tokenizeMarkdown(text),
|
||||
...highlightFencedCode(text),
|
||||
...mentionRangesToHighlightRanges(tokenizeMentions(text, context)),
|
||||
// `~path` is inert: highlighted for the reader, never attached.
|
||||
...pathHighlightRanges(text),
|
||||
];
|
||||
|
||||
for (const token of filterKnownTokens(scanPrefixTokens(text, '/'), context.knownSlashNames)) {
|
||||
ranges.push({ start: token.start, end: token.end, style: 'mentionCommand' });
|
||||
}
|
||||
|
||||
for (const token of filterKnownTokens(scanPrefixTokens(text, '#'), context.knownSnippetTriggers)) {
|
||||
ranges.push({ start: token.start, end: token.end, style: 'mentionSnippet' });
|
||||
}
|
||||
|
||||
if (context.attachmentFilenames.length > 0 && text.includes('[')) {
|
||||
for (const range of findAttachmentCitationRanges(text, [...context.attachmentFilenames])) {
|
||||
ranges.push({ ...range, style: 'mentionFile' });
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Which autocomplete a caret position asks for.
|
||||
*
|
||||
* The composer has four pickers (command, skill, snippet, file/agent mention)
|
||||
* and the rule that opens each of them used to be inlined in a single 90-line
|
||||
* `updateAutocompleteState` callback, duplicating the boundary logic that
|
||||
* `scanPrefixTokens` and `scanMentions` already own. This module answers the
|
||||
* one question the composer actually asks — "given the text and the caret,
|
||||
* what should be open?" — as a pure function, so the editor layer only has to
|
||||
* report the caret and render the result.
|
||||
*
|
||||
* Exactly one trigger can be active, and order matters: the command palette
|
||||
* (a leading `/`) outranks the inline skill picker, which outranks snippets,
|
||||
* which outrank mentions. That precedence is the previous behavior, preserved.
|
||||
*/
|
||||
|
||||
import {
|
||||
getFileMentionAutocompleteQuery,
|
||||
type FileMentionAutocompleteInputSource,
|
||||
} from '../../fileMentionAutocompleteState';
|
||||
|
||||
export type AutocompleteKind = 'command' | 'skill' | 'snippet' | 'mention';
|
||||
|
||||
export interface AutocompleteTrigger {
|
||||
kind: AutocompleteKind;
|
||||
/** Text typed after the sigil, used to filter the picker. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface TriggerContext {
|
||||
/** Shell mode (`!cmd`) disables every picker. */
|
||||
inputMode: 'normal' | 'shell';
|
||||
/** Whether the change that moved the caret came from a paste. */
|
||||
inputSource?: FileMentionAutocompleteInputSource;
|
||||
/** The text that change inserted, when known. */
|
||||
insertedText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sigil opens a picker only at a word boundary — the start of the text or
|
||||
* directly after whitespace. This mirrors `scanPrefixTokens`, but works
|
||||
* backwards from the caret because the token is still being typed.
|
||||
*/
|
||||
const isWordBoundaryBefore = (text: string, index: number): boolean =>
|
||||
index <= 0 || /\s/.test(text[index - 1]);
|
||||
|
||||
/**
|
||||
* The command palette is reserved for a `/` in the very first column, with the
|
||||
* caret still inside the command word and no argument typed yet. Once a space
|
||||
* appears the message is a command invocation, not a search.
|
||||
*/
|
||||
function matchCommandPalette(value: string, cursorPosition: number): AutocompleteTrigger | null {
|
||||
if (!value.startsWith('/')) return null;
|
||||
|
||||
const firstSpace = value.indexOf(' ');
|
||||
if (firstSpace !== -1) return null;
|
||||
|
||||
const firstNewline = value.indexOf('\n');
|
||||
const commandEnd = firstNewline === -1 ? value.length : firstNewline;
|
||||
if (cursorPosition > commandEnd) return null;
|
||||
|
||||
return { kind: 'command', query: value.substring(1, commandEnd) };
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline `/skill` or `#snippet` still being typed: the nearest sigil before
|
||||
* the caret, at a word boundary, with no separator between it and the caret.
|
||||
*/
|
||||
function matchInlineToken(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
sigil: '/' | '#',
|
||||
kind: AutocompleteKind,
|
||||
): AutocompleteTrigger | null {
|
||||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||
const sigilIndex = textBeforeCursor.lastIndexOf(sigil);
|
||||
if (sigilIndex === -1) return null;
|
||||
if (!isWordBoundaryBefore(textBeforeCursor, sigilIndex)) return null;
|
||||
|
||||
const query = textBeforeCursor.substring(sigilIndex + 1);
|
||||
if (query.includes(' ') || query.includes('\n')) return null;
|
||||
|
||||
return { kind, query };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the single autocomplete that the caret asks for, or null when none
|
||||
* applies. Pure: the caller supplies the text and caret, and decides what to
|
||||
* do with the answer.
|
||||
*/
|
||||
export function resolveAutocompleteTrigger(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
context: TriggerContext,
|
||||
): AutocompleteTrigger | null {
|
||||
if (context.inputMode === 'shell') return null;
|
||||
|
||||
return matchCommandPalette(value, cursorPosition)
|
||||
?? matchInlineToken(value, cursorPosition, '/', 'skill')
|
||||
?? matchInlineToken(value, cursorPosition, '#', 'snippet')
|
||||
?? matchMention(value, cursorPosition, context);
|
||||
}
|
||||
|
||||
function matchMention(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
context: TriggerContext,
|
||||
): AutocompleteTrigger | null {
|
||||
const query = getFileMentionAutocompleteQuery({
|
||||
value,
|
||||
cursorPosition,
|
||||
inputSource: context.inputSource,
|
||||
insertedText: context.insertedText,
|
||||
});
|
||||
return query === null ? null : { kind: 'mention', query };
|
||||
}
|
||||
|
||||
export type { FileMentionAutocompleteInputSource };
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
HISTORY_IDLE,
|
||||
INITIAL_HISTORY_STATE,
|
||||
stepNewer,
|
||||
stepOlder,
|
||||
type HistoryState,
|
||||
} from '../useMessageHistory';
|
||||
|
||||
const HISTORY = ['newest', 'middle', 'oldest'];
|
||||
|
||||
/** Apply a sequence of steps, returning the texts shown and the final state. */
|
||||
function walk(
|
||||
steps: Array<{ dir: 'older' | 'newer'; draft?: string }>,
|
||||
history: readonly string[] = HISTORY,
|
||||
) {
|
||||
let state: HistoryState = INITIAL_HISTORY_STATE;
|
||||
const texts: Array<string | null> = [];
|
||||
for (const step of steps) {
|
||||
const result = step.dir === 'older'
|
||||
? stepOlder(state, history, step.draft ?? '')
|
||||
: stepNewer(state, history);
|
||||
state = result.state;
|
||||
texts.push(result.text);
|
||||
}
|
||||
return { texts, state };
|
||||
}
|
||||
|
||||
describe('walking back', () => {
|
||||
test('the first step recalls the most recent message', () => {
|
||||
expect(walk([{ dir: 'older', draft: 'my draft' }]).texts).toEqual(['newest']);
|
||||
});
|
||||
|
||||
test('successive steps go further back', () => {
|
||||
expect(walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }]).texts)
|
||||
.toEqual(['newest', 'middle', 'oldest']);
|
||||
});
|
||||
|
||||
test('the oldest message is the end of the line', () => {
|
||||
const { texts } = walk([
|
||||
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
|
||||
]);
|
||||
expect(texts[3]).toBeNull();
|
||||
});
|
||||
|
||||
test('reaching the end leaves the state where it was', () => {
|
||||
const { state } = walk([
|
||||
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
|
||||
]);
|
||||
expect(state.index).toBe(2);
|
||||
});
|
||||
|
||||
test('empty history recalls nothing', () => {
|
||||
const { texts, state } = walk([{ dir: 'older', draft: 'x' }], []);
|
||||
expect(texts).toEqual([null]);
|
||||
expect(state.index).toBe(HISTORY_IDLE);
|
||||
});
|
||||
|
||||
test('a single-message history has exactly one step', () => {
|
||||
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }], ['only']);
|
||||
expect(texts).toEqual(['only', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coming back', () => {
|
||||
test('returns toward newer messages', () => {
|
||||
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'newer' }]);
|
||||
expect(texts[2]).toBe('newest');
|
||||
});
|
||||
|
||||
test('stepping past the newest restores the stashed draft', () => {
|
||||
const { texts, state } = walk([
|
||||
{ dir: 'older', draft: 'half-written prompt' },
|
||||
{ dir: 'newer' },
|
||||
]);
|
||||
expect(texts[1]).toBe('half-written prompt');
|
||||
expect(state.index).toBe(HISTORY_IDLE);
|
||||
});
|
||||
|
||||
test('an empty draft is restored as empty rather than left on a message', () => {
|
||||
const { texts } = walk([{ dir: 'older', draft: '' }, { dir: 'newer' }]);
|
||||
expect(texts[1]).toBe('');
|
||||
});
|
||||
|
||||
test('coming back when not browsing does nothing', () => {
|
||||
expect(walk([{ dir: 'newer' }]).texts).toEqual([null]);
|
||||
});
|
||||
|
||||
test('the draft is stashed on entry, not overwritten by recalled text', () => {
|
||||
// The second `older` passes recalled text as the current text; it must
|
||||
// not replace what the user actually typed.
|
||||
const { texts } = walk([
|
||||
{ dir: 'older', draft: 'original draft' },
|
||||
{ dir: 'older', draft: 'newest' },
|
||||
{ dir: 'newer' },
|
||||
{ dir: 'newer' },
|
||||
]);
|
||||
expect(texts[3]).toBe('original draft');
|
||||
});
|
||||
|
||||
test('the stash is cleared once restored', () => {
|
||||
const { state } = walk([{ dir: 'older', draft: 'draft' }, { dir: 'newer' }]);
|
||||
expect(state.stashedDraft).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a shrinking history', () => {
|
||||
test('an index past the end of a shorter history cannot step further back', () => {
|
||||
const state: HistoryState = { index: 5, stashedDraft: 'draft' };
|
||||
expect(stepOlder(state, HISTORY, 'x').text).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Where an autocomplete popup goes in focus mode.
|
||||
*
|
||||
* In the normal composer each picker anchors to the composer's own edge, which
|
||||
* is close enough to the text. In focus mode the composer fills the surface,
|
||||
* so an edge-anchored picker would sit far from what the user is typing —
|
||||
* there it follows the caret instead.
|
||||
*
|
||||
* The editor reports the caret's viewport position directly, so this only has
|
||||
* to decide whether the popup fits below the caret or has to flip above it,
|
||||
* and keep it inside the composer horizontally.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
import type { AutocompleteKind } from '../language/triggers';
|
||||
import type { AutocompleteOverlayPosition } from '../ui/ComposerAutocompletePopups';
|
||||
|
||||
export interface AutocompletePositionOptions {
|
||||
/** Only focus mode places popups at the caret. */
|
||||
enabled: boolean;
|
||||
openAutocomplete: AutocompleteKind | null;
|
||||
/** Recompute whenever the text changes, since the caret moves with it. */
|
||||
message: string;
|
||||
editorRef: React.RefObject<ComposerEditorHandle | null>;
|
||||
/** The composer box the popup is positioned within. */
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function useAutocompletePosition(options: AutocompletePositionOptions) {
|
||||
const { enabled, openAutocomplete, message, editorRef, containerRef } = options;
|
||||
const [position, setPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||
|
||||
const update = React.useCallback(() => {
|
||||
if (!enabled) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (openAutocomplete === null) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!editor || !container) return;
|
||||
|
||||
// The editor reports the caret's viewport position directly, so the
|
||||
// popup no longer has to be placed from a hand-measured text mirror.
|
||||
const caret = editor.caretCoords();
|
||||
if (!caret) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const caretY = caret.top - containerRect.top;
|
||||
const caretX = caret.left - containerRect.left;
|
||||
|
||||
const popupMargin = 8;
|
||||
const estimatedPopupHeight = 260;
|
||||
const spaceAbove = caretY - popupMargin;
|
||||
const spaceBelow = containerRect.height - caretY - popupMargin;
|
||||
const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above';
|
||||
|
||||
const desiredWidth = openAutocomplete === 'mention' ? 520 : openAutocomplete === 'skill' ? 360 : 450;
|
||||
const clampedLeft = Math.max(
|
||||
popupMargin,
|
||||
Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin)
|
||||
);
|
||||
|
||||
const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove));
|
||||
|
||||
setPosition({
|
||||
top: place === 'below' ? caretY + 22 : caretY - 6,
|
||||
left: clampedLeft,
|
||||
place,
|
||||
maxHeight,
|
||||
});
|
||||
}, [containerRef, editorRef, enabled, openAutocomplete]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
update();
|
||||
}, [
|
||||
update,
|
||||
message,
|
||||
openAutocomplete,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onResize = () => update();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [enabled, update]);
|
||||
|
||||
return { position, update };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Per-session draft persistence for the composer.
|
||||
*
|
||||
* A draft belongs to a (runtime, directory, session) identity. Switching any
|
||||
* of those saves the outgoing draft and restores the incoming one, so moving
|
||||
* between sessions never loses typed text and never leaks it into the wrong
|
||||
* conversation.
|
||||
*
|
||||
* Writes are debounced while typing but forced at every edge where the page
|
||||
* may stop running — tab hidden, frozen, unloading, unmounting — because a
|
||||
* pending timer is not a saved draft.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
getChatDraftIdentityKey,
|
||||
readChatDraft,
|
||||
subscribeChatDraftDeletion,
|
||||
writeChatDraft,
|
||||
type ChatDraftIdentity,
|
||||
} from '@/lib/chatDraftPersistence';
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Identifies a stored draft's content. Comparing signatures lets a repeated
|
||||
* save of unchanged text skip the write entirely.
|
||||
*/
|
||||
function draftSignature(text: string, confirmedMentions: Iterable<string>): string {
|
||||
// NUL separates the fields: no draft text can contain it, so two different
|
||||
// (text, mentions) pairs can never produce the same signature.
|
||||
return `${text}\u0000${[...confirmedMentions].sort().join('\u0000')}`;
|
||||
}
|
||||
|
||||
export interface ComposerDraftOptions {
|
||||
/** Current composer text. */
|
||||
message: string;
|
||||
/** Latest text without waiting for a render, for flush-on-unload paths. */
|
||||
messageRef: React.RefObject<string>;
|
||||
setMessage: (text: string) => void;
|
||||
/**
|
||||
* Mention paths the user confirmed through the picker. Mutated here:
|
||||
* mentions no longer present in the text are dropped before saving.
|
||||
*/
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ComposerDraftControls {
|
||||
/**
|
||||
* Write a draft now, bypassing the debounce. Used on submit, where the
|
||||
* cleared composer must be stored before the send resolves.
|
||||
*/
|
||||
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
|
||||
}
|
||||
|
||||
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
|
||||
const {
|
||||
message,
|
||||
messageRef,
|
||||
setMessage,
|
||||
confirmedMentionsRef,
|
||||
identity,
|
||||
persistEnabled,
|
||||
initialDraft,
|
||||
onIdentityChange,
|
||||
onDraftRestored,
|
||||
} = options;
|
||||
|
||||
const persistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipNextPersistRef = React.useRef(false);
|
||||
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
|
||||
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
|
||||
|
||||
// Callbacks reach the effects through a ref so a caller passing inline
|
||||
// functions does not re-run the persistence effects on every render.
|
||||
const callbacksRef = React.useRef({ onIdentityChange, onDraftRestored });
|
||||
callbacksRef.current = { onIdentityChange, onDraftRestored };
|
||||
|
||||
React.useEffect(() => {
|
||||
currentIdentityRef.current = identity;
|
||||
}, [identity]);
|
||||
|
||||
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
|
||||
if (!target) return;
|
||||
const key = getChatDraftIdentityKey(target);
|
||||
|
||||
// Only keep confirmed mentions the draft still contains: a mention the
|
||||
// user deleted must not resurrect as a file reference on restore.
|
||||
const activeMentions = new Set<string>();
|
||||
for (const mention of confirmedMentionsRef.current) {
|
||||
if (draft.includes(`@${mention}`)) activeMentions.add(mention);
|
||||
}
|
||||
confirmedMentionsRef.current = activeMentions;
|
||||
|
||||
const signature = draftSignature(draft, activeMentions);
|
||||
if (lastPersistedRef.current.get(key) === signature) return;
|
||||
|
||||
writeChatDraft(target, draft, activeMentions);
|
||||
lastPersistedRef.current.set(key, signature);
|
||||
}, [confirmedMentionsRef]);
|
||||
|
||||
const clearPending = React.useCallback(() => {
|
||||
if (!persistTimerRef.current) return;
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Mount: a restored draft is selected so typing replaces it; with the
|
||||
// setting off it is discarded instead of silently kept.
|
||||
const handledInitialRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (handledInitialRef.current) return;
|
||||
handledInitialRef.current = true;
|
||||
if (!initialDraft.text) return;
|
||||
|
||||
if (!persistEnabled) {
|
||||
setMessage('');
|
||||
writeChatDraft(initialDraft.identity, '', []);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
|
||||
// Runs once; the initial draft is captured at mount by design.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [persistEnabled]);
|
||||
|
||||
// Identity switch: save the outgoing draft, load the incoming one.
|
||||
const previousIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
|
||||
React.useEffect(() => {
|
||||
const previous = previousIdentityRef.current;
|
||||
const previousKey = previous ? getChatDraftIdentityKey(previous) : null;
|
||||
const currentKey = identity ? getChatDraftIdentityKey(identity) : null;
|
||||
if (previousKey === currentKey) return;
|
||||
|
||||
previousIdentityRef.current = identity;
|
||||
callbacksRef.current.onIdentityChange?.();
|
||||
clearPending();
|
||||
// The incoming draft is being written into state right now; the
|
||||
// debounced effect must not immediately write it back out.
|
||||
skipNextPersistRef.current = true;
|
||||
|
||||
if (!persistEnabled) {
|
||||
setMessage('');
|
||||
confirmedMentionsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
persistNow(previous, messageRef.current);
|
||||
const restored = readChatDraft(identity);
|
||||
setMessage(restored.text);
|
||||
confirmedMentionsRef.current = restored.confirmedMentions;
|
||||
if (restored.text) {
|
||||
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.());
|
||||
}
|
||||
}, [clearPending, confirmedMentionsRef, identity, messageRef, 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('', []));
|
||||
|
||||
const current = currentIdentityRef.current;
|
||||
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
|
||||
|
||||
clearPending();
|
||||
skipNextPersistRef.current = true;
|
||||
messageRef.current = '';
|
||||
confirmedMentionsRef.current = new Set();
|
||||
setMessage('');
|
||||
}), [clearPending, confirmedMentionsRef, messageRef, setMessage]);
|
||||
|
||||
// Debounced write while typing.
|
||||
React.useEffect(() => {
|
||||
if (!persistEnabled) {
|
||||
clearPending();
|
||||
persistNow(identity, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipNextPersistRef.current) {
|
||||
skipNextPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearPending();
|
||||
const draftSnapshot = message;
|
||||
const identitySnapshot = identity;
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
persistTimerRef.current = null;
|
||||
persistNow(identitySnapshot, draftSnapshot);
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
|
||||
return clearPending;
|
||||
}, [clearPending, identity, message, persistEnabled, persistNow]);
|
||||
|
||||
// Force a write wherever the page may stop running before the timer fires.
|
||||
React.useEffect(() => {
|
||||
const flush = () => {
|
||||
clearPending();
|
||||
if (persistEnabled) persistNow(currentIdentityRef.current, messageRef.current);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') flush();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
document.addEventListener('freeze', flush);
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
document.removeEventListener('freeze', flush);
|
||||
window.removeEventListener('pagehide', flush);
|
||||
flush();
|
||||
};
|
||||
}, [clearPending, messageRef, persistEnabled, persistNow]);
|
||||
|
||||
return { persistNow };
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Choosing where a new session will run.
|
||||
*
|
||||
* The new-session draft targets a project and a directory within it — the
|
||||
* project root or one of its worktrees. Both are discovered lazily: whether a
|
||||
* project is even a git repository is unknown until asked, and its branch list
|
||||
* is served stale-while-revalidate so a cached list appears instantly and
|
||||
* refreshes behind it.
|
||||
*
|
||||
* The awkward part this hook contains is that the draft can point at a
|
||||
* directory that does not exist yet — a worktree being created. Such a
|
||||
* directory must survive not appearing in the list, or the selector would snap
|
||||
* back to the project root mid-creation and the session would be started in
|
||||
* the wrong place.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
|
||||
/** How long a cached branch list is served before it is refreshed. */
|
||||
const BRANCHES_SWR_TTL_MS = 30_000;
|
||||
|
||||
export interface DraftTargetProject {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
|
||||
iconBackground?: string | null;
|
||||
}
|
||||
|
||||
/** A project's display name, falling back to its directory name. */
|
||||
export function getProjectDisplayLabel(project: { label?: string; path: string }): string {
|
||||
return project.label?.trim() || formatDirectoryName(project.path);
|
||||
}
|
||||
|
||||
export function useDraftTarget(enabled: boolean) {
|
||||
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
|
||||
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
|
||||
const selectedDraftProject = React.useMemo(() => {
|
||||
const explicit = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const active = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId) ?? null
|
||||
: null;
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const selectedDraftProjectPath = React.useMemo(
|
||||
() => normalizePath(selectedDraftProject?.path ?? null),
|
||||
[selectedDraftProject?.path],
|
||||
);
|
||||
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
|
||||
|
||||
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
|
||||
const selectedDraftProjectBranchesFetchedAt = useGitStore(
|
||||
(s) => (selectedDraftProjectPath ? s.directories.get(selectedDraftProjectPath)?.lastBranchesFetch ?? 0 : 0),
|
||||
);
|
||||
const selectedDraftProjectIsGitRepo = useIsGitRepo(selectedDraftProjectPath);
|
||||
const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchGitStatus(selectedDraftProjectPath, runtimeGit, { silent: true });
|
||||
}, [fetchGitStatus, runtimeGit, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, enabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit || selectedDraftProjectIsGitRepo !== true) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate: branches seeded from the persisted cache show
|
||||
// instantly. Refresh based on staleness (not mere presence) so a cached
|
||||
// list can't go stale, while only showing the discovering spinner when
|
||||
// there is nothing to display yet.
|
||||
const isStale =
|
||||
!selectedDraftProjectBranchesFetchedAt ||
|
||||
Date.now() - selectedDraftProjectBranchesFetchedAt > BRANCHES_SWR_TTL_MS;
|
||||
|
||||
if (hasDraftBranchList && !isStale) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsDiscoveringDraftBranches(!hasDraftBranchList);
|
||||
|
||||
void fetchBranches(selectedDraftProjectPath, runtimeGit)
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranchesFetchedAt, hasDraftBranchList, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, enabled]);
|
||||
|
||||
const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
|
||||
|
||||
const projectRootBranchOption = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return null;
|
||||
}
|
||||
const value = normalizePath(selectedDraftProject.path);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!selectedDraftProjectCurrentBranch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
value,
|
||||
label: selectedDraftProjectCurrentBranch,
|
||||
};
|
||||
}, [selectedDraftProject, selectedDraftProjectCurrentBranch]);
|
||||
|
||||
const worktreeBranchOptions = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const worktrees = (() => {
|
||||
if (!selectedDraftProjectPath) {
|
||||
return [];
|
||||
}
|
||||
return availableWorktreesByProject.get(selectedDraftProjectPath)
|
||||
?? availableWorktreesByProject.get(selectedDraftProject.path)
|
||||
?? [];
|
||||
})();
|
||||
|
||||
return buildSessionTargetOptions({
|
||||
projectRoot: normalizePath(selectedDraftProject.path) ?? '',
|
||||
rootBranch: selectedDraftProjectCurrentBranch,
|
||||
worktrees,
|
||||
pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null,
|
||||
}).filter((option) => option.kind === 'worktree');
|
||||
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]);
|
||||
|
||||
const selectedDraftDirectory = React.useMemo(
|
||||
() => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null)
|
||||
?? normalizePath(newSessionDraft?.directoryOverride ?? null)
|
||||
?? selectedDraftProjectPath,
|
||||
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||
);
|
||||
|
||||
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
||||
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
||||
return Boolean(
|
||||
newSessionDraft?.preserveDirectoryOverride
|
||||
||
|
||||
newSessionDraft?.pendingWorktreeRequestId
|
||||
|| (pendingDirectory && pendingDirectory === selectedDraftDirectory)
|
||||
);
|
||||
}, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory]);
|
||||
|
||||
const draftBranchItems = React.useMemo(() => {
|
||||
const baseItems: Array<{ value: string; label: string }> = [];
|
||||
if (projectRootBranchOption) {
|
||||
baseItems.push(projectRootBranchOption);
|
||||
}
|
||||
baseItems.push(...worktreeBranchOptions);
|
||||
|
||||
if (!selectedDraftDirectory) {
|
||||
return baseItems;
|
||||
}
|
||||
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
|
||||
return baseItems;
|
||||
}
|
||||
if (!shouldKeepMissingSelectedDraftDirectory) {
|
||||
return baseItems;
|
||||
}
|
||||
return [
|
||||
...baseItems,
|
||||
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
|
||||
];
|
||||
}, [projectRootBranchOption, selectedDraftDirectory, shouldKeepMissingSelectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
const selectedDraftBranchLabel = React.useMemo(() => {
|
||||
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
|
||||
if (!selectedValue) {
|
||||
return null;
|
||||
}
|
||||
return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue);
|
||||
}, [draftBranchItems, selectedDraftDirectory]);
|
||||
|
||||
|
||||
const selectedDraftBranchIsKnown = React.useMemo(() => {
|
||||
if (!selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
if (projectRootBranchOption?.value === selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
|
||||
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!newSessionDraft?.open || !newSessionDraft?.preserveDirectoryOverride) {
|
||||
return;
|
||||
}
|
||||
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
|
||||
return;
|
||||
}
|
||||
useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false);
|
||||
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
|
||||
|
||||
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
||||
if (selectedDraftProjectIsGitRepo !== true) {
|
||||
return false;
|
||||
}
|
||||
if (isDiscoveringDraftBranches) {
|
||||
return false;
|
||||
}
|
||||
if (projectRootBranchOption) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.length > 0;
|
||||
}, [isDiscoveringDraftBranches, projectRootBranchOption, selectedDraftProjectIsGitRepo, worktreeBranchOptions.length]);
|
||||
|
||||
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
||||
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
||||
return;
|
||||
}
|
||||
const project = projects.find((entry) => entry.id === projectId);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (activeProjectId !== projectId) {
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId,
|
||||
directoryOverride: project.path,
|
||||
}, { force: true });
|
||||
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
||||
|
||||
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
||||
const draft = useSessionUIStore.getState().newSessionDraft;
|
||||
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
||||
return;
|
||||
}
|
||||
if (!selectedDraftProject) {
|
||||
return;
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId: selectedDraftProject.id,
|
||||
directoryOverride: directory,
|
||||
}, { force: true });
|
||||
}, [selectedDraftProject, setNewSessionDraftTarget]);
|
||||
return {
|
||||
projects,
|
||||
selectedDraftProject,
|
||||
selectedDraftProjectPath,
|
||||
draftProjectLabel,
|
||||
selectedDraftDirectory,
|
||||
selectedDraftBranchLabel,
|
||||
selectedDraftBranchIsKnown,
|
||||
projectRootBranchOption,
|
||||
worktreeBranchOptions,
|
||||
draftBranchItems,
|
||||
shouldShowDraftBranchSelector,
|
||||
handleDraftProjectChange,
|
||||
handleDraftDirectoryChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Walking back through previously sent messages with the arrow keys.
|
||||
*
|
||||
* Entering history stashes whatever was typed so leaving it returns the user's
|
||||
* own text rather than the last recalled message — the composer is not a
|
||||
* terminal, and losing a half-written prompt to an arrow key is worse than not
|
||||
* having history at all.
|
||||
*
|
||||
* Index 0 is the most recent message and higher indices are older, matching
|
||||
* how the keys read: up goes further back.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/** Not browsing history. */
|
||||
export const HISTORY_IDLE = -1;
|
||||
|
||||
export interface HistoryState {
|
||||
/** Index into the history, or HISTORY_IDLE when showing the user's draft. */
|
||||
index: number;
|
||||
/** The draft stashed on entry, restored on the way back out. */
|
||||
stashedDraft: string;
|
||||
}
|
||||
|
||||
export const INITIAL_HISTORY_STATE: HistoryState = { index: HISTORY_IDLE, stashedDraft: '' };
|
||||
|
||||
/**
|
||||
* The outcome of an arrow key: the next state, and the text the composer
|
||||
* should show. A null text means the key does nothing and the composer keeps
|
||||
* what it has.
|
||||
*/
|
||||
export interface HistoryStep {
|
||||
state: HistoryState;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
const unchanged = (state: HistoryState): HistoryStep => ({ state, text: null });
|
||||
|
||||
/** Step further back in history. `currentText` is stashed on entry. */
|
||||
export function stepOlder(
|
||||
state: HistoryState,
|
||||
history: readonly string[],
|
||||
currentText: string,
|
||||
): HistoryStep {
|
||||
if (history.length === 0) return unchanged(state);
|
||||
|
||||
if (state.index === HISTORY_IDLE) {
|
||||
return { state: { index: 0, stashedDraft: currentText }, text: history[0] };
|
||||
}
|
||||
if (state.index >= history.length - 1) return unchanged(state);
|
||||
|
||||
const index = state.index + 1;
|
||||
return { state: { ...state, index }, text: history[index] };
|
||||
}
|
||||
|
||||
/** Step back toward the draft, restoring it once past the newest message. */
|
||||
export function stepNewer(state: HistoryState, history: readonly string[]): HistoryStep {
|
||||
if (state.index === HISTORY_IDLE) return unchanged(state);
|
||||
|
||||
if (state.index === 0) {
|
||||
return { state: INITIAL_HISTORY_STATE, text: state.stashedDraft };
|
||||
}
|
||||
|
||||
const index = state.index - 1;
|
||||
return { state: { ...state, index }, text: history[index] };
|
||||
}
|
||||
|
||||
export interface MessageHistory {
|
||||
/** True while showing a recalled message rather than the user's draft. */
|
||||
isBrowsing: boolean;
|
||||
/** Recall an older message; returns null when already at the oldest. */
|
||||
older: (currentText: string) => string | null;
|
||||
/** Return toward the draft; returns null when not browsing. */
|
||||
newer: () => string | null;
|
||||
/** Leave history, discarding the stashed draft. Called after a send. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useMessageHistory(history: readonly string[]): MessageHistory {
|
||||
const [state, setState] = React.useState<HistoryState>(INITIAL_HISTORY_STATE);
|
||||
|
||||
const older = React.useCallback((currentText: string) => {
|
||||
const step = stepOlder(state, history, currentText);
|
||||
if (step.text === null) return null;
|
||||
setState(step.state);
|
||||
return step.text;
|
||||
}, [history, state]);
|
||||
|
||||
const newer = React.useCallback(() => {
|
||||
const step = stepNewer(state, history);
|
||||
if (step.text === null) return null;
|
||||
setState(step.state);
|
||||
return step.text;
|
||||
}, [history, state]);
|
||||
|
||||
const reset = React.useCallback(() => setState(INITIAL_HISTORY_STATE), []);
|
||||
|
||||
return { isBrowsing: state.index !== HISTORY_IDLE, older, newer, reset };
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* The mobile composer's pill ↔ full-composer state machine.
|
||||
*
|
||||
* With the keyboard closed the composer collapses into a narrow pill; any
|
||||
* interaction expands it back. The swap is deliberately instant and
|
||||
* synchronized with the keyboard choreography, so the chat compensates
|
||||
* keyboard and composer height in a single motion rather than a staircase.
|
||||
*
|
||||
* Most of the code here is not the state machine itself but the corrections
|
||||
* that keep it from fighting the platform: mobile browsers dismiss the
|
||||
* keyboard on a tap before the click lands, iOS refuses programmatic focus
|
||||
* outside a gesture, WebKit leaves the layout viewport panned after the
|
||||
* keyboard hides, and overlay chains hand off through a frame where nothing
|
||||
* is open. Every timeout and flushSync below marks one of those, and none of
|
||||
* them is verifiable outside a real device.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
import { observeEditorFocus } from '@/lib/hardwareKeyboard';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
/**
|
||||
* Everything that must keep the composer expanded even with the keyboard
|
||||
* down. Collapsing under an open sheet would unmount the focused editor and
|
||||
* kill the keyboard the sheet is about to hand back.
|
||||
*/
|
||||
export interface MobileComposerHolders {
|
||||
controlsPanelOpen: boolean;
|
||||
attachMenuOpen: boolean;
|
||||
draftPickerOpen: boolean;
|
||||
issuePickerOpen: boolean;
|
||||
prPickerOpen: boolean;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export interface MobileComposerShellOptions {
|
||||
isMobile: boolean;
|
||||
editorRef: React.RefObject<ComposerEditorHandle | null>;
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
setExpandedInput: (expanded: boolean) => void;
|
||||
holders: MobileComposerHolders;
|
||||
/**
|
||||
* Keep the full composer up permanently and never fall back to the pill.
|
||||
* The pill exists to buy screen back from the soft keyboard; with a
|
||||
* hardware keyboard on a tablet there is no soft keyboard to hide from,
|
||||
* and collapsing between keystrokes would only cost the user a tap.
|
||||
*/
|
||||
alwaysExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface MobileComposerShell {
|
||||
/** The full composer is showing rather than the collapsed pill. */
|
||||
expanded: boolean;
|
||||
/** The editor has focus; the best keyboard proxy a browser offers. */
|
||||
focused: boolean;
|
||||
/** A MobileOverlayPanel is mounted in the shared portal root. */
|
||||
overlayHostBusy: boolean;
|
||||
dictationActive: boolean;
|
||||
/** Expand and focus, synchronously, from inside a user gesture. */
|
||||
expand: () => void;
|
||||
onDictationActiveChange: (active: boolean) => void;
|
||||
onEditorFocus: () => void;
|
||||
onEditorBlur: () => void;
|
||||
/** Suppress the keyboard restore when another overlay opens next. */
|
||||
skipNextOverlayCloseRestore: () => void;
|
||||
/** Cancel a pending keyboard restore entirely (a native picker takes over). */
|
||||
cancelOverlayCloseRestore: () => void;
|
||||
}
|
||||
|
||||
export function useMobileComposerShell(
|
||||
options: MobileComposerShellOptions,
|
||||
): MobileComposerShell {
|
||||
const { isMobile, editorRef, formRef, setExpandedInput, holders, alwaysExpanded = false } = options;
|
||||
|
||||
const [expanded, setExpanded] = React.useState(alwaysExpanded && isMobile);
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [overlayHostBusy, setOverlayHostBusy] = React.useState(false);
|
||||
const [dictationActive, setDictationActive] = React.useState(false);
|
||||
|
||||
// Set while an expansion is settling (focus or dictation not yet active) so
|
||||
// the collapse watcher does not immediately fold it back into the pill.
|
||||
const expandIntentRef = React.useRef<'focus' | null>(null);
|
||||
const lastBlurAtRef = React.useRef(0);
|
||||
const restoreKeyboardRef = React.useRef(false);
|
||||
const blurTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const expandedRef = React.useRef(expanded);
|
||||
React.useEffect(() => {
|
||||
expandedRef.current = expanded;
|
||||
});
|
||||
|
||||
// A hardware keyboard can be attached (or detached) at any moment, so this
|
||||
// is a live condition rather than a mount-time one. Detaching does NOT
|
||||
// force a collapse — the normal idle/keyboard-hide paths take over again.
|
||||
const alwaysExpandedRef = React.useRef(alwaysExpanded);
|
||||
alwaysExpandedRef.current = alwaysExpanded;
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || !alwaysExpanded) return;
|
||||
setExpanded(true);
|
||||
}, [alwaysExpanded, isMobile]);
|
||||
|
||||
// The draft screen restructures itself around the composer: its starter
|
||||
// chips leave once the full composer is up, and its centered title
|
||||
// re-centers over whatever room remains. Announced as a root class from a
|
||||
// layout effect so the restructure lands in the SAME frame as the pill
|
||||
// swap — keyed on the keyboard instead (oc-keyboard-open arrives with the
|
||||
// keyboardWillShow bridge event, ~100ms later), the chips vanished
|
||||
// mid-rise as a second visible jump.
|
||||
//
|
||||
// Not announced while `alwaysExpanded`: there the full composer is the
|
||||
// resting state, not a keyboard takeover, so claiming otherwise would hide
|
||||
// the starters permanently. The keyboard classes still cover that case.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('oc-composer-expanded', expanded && !alwaysExpanded);
|
||||
return () => root.classList.remove('oc-composer-expanded');
|
||||
}, [alwaysExpanded, expanded, isMobile]);
|
||||
|
||||
const expand = React.useCallback(() => {
|
||||
expandIntentRef.current = 'focus';
|
||||
// flushSync so the editor exists NOW and focus() still runs inside the
|
||||
// gesture's call stack: mobile browsers only open the soft keyboard for
|
||||
// focus calls made synchronously from the tap (an rAF here worked in
|
||||
// the Capacitor WebView but not in Safari or Chrome).
|
||||
flushSync(() => setExpanded(true));
|
||||
|
||||
if (isCapacitorApp()) {
|
||||
// Timing tuned on device, against WKWebView pausing frame
|
||||
// presentation while the keyboard transition runs:
|
||||
// - focus in the same task as the swap → the pause starts before
|
||||
// the swap's first frame, so the pill stays on glass until the
|
||||
// keyboard is nearly up;
|
||||
// - focus two frames later → the swap is presented first and the
|
||||
// keyboard only then begins, a visibly sequential two-step.
|
||||
// Focusing INSIDE the first frame after the commit threads the
|
||||
// needle: the swap's frame is already in the rendering pipeline
|
||||
// when the keyboard transaction starts, so the keyboard rises from
|
||||
// the tap and the composer appears during the rise. The Capacitor
|
||||
// WebView raises the keyboard for a focus() outside the gesture
|
||||
// task (browsers do not, hence the split); the choreography
|
||||
// positions everything, so preventScroll stays on.
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile browsers only open the soft keyboard for focus calls made
|
||||
// synchronously from the tap; their native reveal is also the only
|
||||
// thing that positions the composer, so no preventScroll.
|
||||
editorRef.current?.focus({ preventScroll: false });
|
||||
}, [editorRef]);
|
||||
|
||||
const onDictationActiveChange = React.useCallback((active: boolean) => {
|
||||
setDictationActive(active);
|
||||
if (active) {
|
||||
expandIntentRef.current = null;
|
||||
// Dictation went live, possibly from the pill: switch straight into
|
||||
// the voice variant of the full composer.
|
||||
if (!expandedRef.current) setExpanded(true);
|
||||
return;
|
||||
}
|
||||
// Dictation ended. The insert flow hands focus back a tick later — if
|
||||
// that happened, stay expanded; otherwise (cancel, discard,
|
||||
// insert-and-send) collapse straight back to the pill rather than
|
||||
// parking on the normal composer for the usual grace period.
|
||||
window.setTimeout(() => {
|
||||
if (!expandedRef.current || alwaysExpandedRef.current) return;
|
||||
if (editorRef.current?.isFocused()) return;
|
||||
setExpanded(false);
|
||||
setExpandedInput(false);
|
||||
}, 30);
|
||||
}, [editorRef, setExpandedInput]);
|
||||
|
||||
// Watch the shared overlay portal root: any mounted MobileOverlayPanel
|
||||
// counts as busy. Observing the host catches overlays whose open state
|
||||
// lives in other components without threading it through here.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || typeof document === 'undefined') return;
|
||||
let host = document.getElementById('mobile-overlay-root');
|
||||
if (!host) {
|
||||
// Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot.
|
||||
host = document.createElement('div');
|
||||
host.id = 'mobile-overlay-root';
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const hostEl = host;
|
||||
const update = () => setOverlayHostBusy(hostEl.childElementCount > 0);
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(hostEl, { childList: true });
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
const overlayOpen = overlayHostBusy
|
||||
|| holders.controlsPanelOpen
|
||||
|| holders.attachMenuOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen;
|
||||
|
||||
// Installed PWA (standalone): a focus() from a bare timeout is outside the
|
||||
// user gesture and iOS refuses to raise the keyboard for it (Safari
|
||||
// in-browser is lenient). MobileOverlayPanel dispatches
|
||||
// 'oc:mobile-overlay-closed' synchronously from the same React flush as the
|
||||
// click that closed it — refocus right there, while the gesture is live.
|
||||
const pickerDialogsOpenRef = React.useRef(false);
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen;
|
||||
const skipNextCloseRestoreRef = React.useRef(false);
|
||||
const openSheetCountRef = React.useRef(0);
|
||||
const holdFocusUntilRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return;
|
||||
if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return;
|
||||
|
||||
const handleOverlayOpened = () => {
|
||||
openSheetCountRef.current += 1;
|
||||
};
|
||||
const handleOverlayClosed = () => {
|
||||
// Counter instead of a DOM check: the close event fires from a
|
||||
// layout-effect cleanup, when the closing sheet's portal nodes may
|
||||
// still be attached — the DOM cannot tell "this sheet going away"
|
||||
// from "another sheet still up".
|
||||
openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1);
|
||||
if (skipNextCloseRestoreRef.current) {
|
||||
skipNextCloseRestoreRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!restoreKeyboardRef.current) return;
|
||||
if (pickerDialogsOpenRef.current) return;
|
||||
if (openSheetCountRef.current > 0) return;
|
||||
restoreKeyboardRef.current = false;
|
||||
|
||||
// iOS can still dismiss the freshly-raised keyboard when the tap
|
||||
// that closed the overlay finishes over non-input content — hold
|
||||
// focus through that window (see onEditorBlur).
|
||||
holdFocusUntilRef.current = Date.now() + 600;
|
||||
editorRef.current?.focus();
|
||||
// The native focus lands mid-commit; React's delegated onFocus may
|
||||
// not make it into this flush, leaving the composer un-busy for a
|
||||
// beat — enough for the collapse timer to unmount the focused
|
||||
// editor and kill the rising keyboard. Set the state explicitly.
|
||||
if (editorRef.current?.isFocused()) setFocused(true);
|
||||
|
||||
// iOS reveals a field above the keyboard only for user-initiated
|
||||
// focus; a programmatic one leaves the composer parked behind it.
|
||||
// Reveal once the keyboard has mostly risen, and again after it
|
||||
// settles.
|
||||
const reveal = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor?.isFocused()) return;
|
||||
// Align the BOTTOM of the whole form with the visible bottom:
|
||||
// revealing the editor alone leaves the footer icon row parked
|
||||
// behind the keyboard accessory bar.
|
||||
(formRef.current ?? editor.getScrollDOM())?.scrollIntoView({ block: 'end' });
|
||||
};
|
||||
window.setTimeout(reveal, 300);
|
||||
window.setTimeout(reveal, 650);
|
||||
};
|
||||
|
||||
window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened);
|
||||
window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed);
|
||||
return () => {
|
||||
window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened);
|
||||
window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed);
|
||||
};
|
||||
}, [editorRef, formRef, isMobile]);
|
||||
|
||||
// If the keyboard was open (or closed moments ago by the overlay's own
|
||||
// blur) when an overlay appeared, bring it back once every overlay is gone.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (overlayOpen) {
|
||||
if (focused || Date.now() - lastBlurAtRef.current < 800) {
|
||||
restoreKeyboardRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!restoreKeyboardRef.current) return;
|
||||
// Debounced: overlay chains hand off with a frame of "nothing open"
|
||||
// between steps (attach sheet closes, then the picker opens). Restoring
|
||||
// instantly in that gap would pop the keyboard open inside the next
|
||||
// overlay — wait out the gap and cancel if another overlay appears.
|
||||
const timer = window.setTimeout(() => {
|
||||
restoreKeyboardRef.current = false;
|
||||
// Browsers need their native scroll-into-view (see expand).
|
||||
editorRef.current?.focus({ preventScroll: isCapacitorApp() });
|
||||
}, 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [editorRef, focused, isMobile, overlayOpen]);
|
||||
|
||||
// Fold back into the pill once nothing keeps the composer open. The short
|
||||
// delay bridges focus moving between composer controls.
|
||||
const busy = focused
|
||||
|| overlayHostBusy
|
||||
|| dictationActive
|
||||
|| holders.controlsPanelOpen
|
||||
|| holders.attachMenuOpen
|
||||
|| holders.draftPickerOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen
|
||||
|| holders.isDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || !expanded || busy || alwaysExpanded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
// Authoritative DOM check: the React focus state can lag a
|
||||
// programmatic refocus (the overlay-close restore above).
|
||||
// Collapsing would unmount the focused editor and kill the keyboard.
|
||||
if (editorRef.current?.isFocused()) return;
|
||||
expandIntentRef.current = null;
|
||||
setExpanded(false);
|
||||
setExpandedInput(false);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [alwaysExpanded, busy, editorRef, expanded, isMobile, setExpandedInput]);
|
||||
|
||||
const busyRef = React.useRef(false);
|
||||
busyRef.current = busy;
|
||||
|
||||
// Browser counterpart of Capacitor's oc-keyboard-open root class (which is
|
||||
// driven by native keyboard events): the focused composer is the best
|
||||
// keyboard proxy a browser has. CSS keyed on it hides the draft starters
|
||||
// while typing, mirroring the native app.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
if (focused) {
|
||||
root.classList.add('oc-browser-keyboard-open');
|
||||
} else {
|
||||
root.classList.remove('oc-browser-keyboard-open');
|
||||
// Installed PWA: after the keyboard dismisses, WebKit can leave the
|
||||
// layout viewport stuck smaller or panned (content shifted up with
|
||||
// a dead strip at the bottom) until something forces a recompute. A
|
||||
// zero scroll after the exit animation settles snaps it back, and
|
||||
// is harmless when nothing is stuck.
|
||||
if (window.matchMedia?.('(display-mode: standalone)')?.matches) {
|
||||
window.setTimeout(() => {
|
||||
if (root.classList.contains('oc-browser-keyboard-open')) return;
|
||||
window.scrollTo(0, 0);
|
||||
document.body.scrollTop = 0;
|
||||
root.scrollTop = 0;
|
||||
}, 350);
|
||||
}
|
||||
}
|
||||
return () => root.classList.remove('oc-browser-keyboard-open');
|
||||
}, [focused, isMobile]);
|
||||
|
||||
// Capacitor: collapse in the SAME frame the keyboard starts hiding. The
|
||||
// hide choreography dispatches oc:keyboard-intent BEFORE restoring the
|
||||
// shell layout and measuring the chat compensation; flushSync commits the
|
||||
// pill swap first, so keyboard land and composer shrink are measured — and
|
||||
// compensated — as one motion instead of a two-step staircase. The delayed
|
||||
// effect above remains the fallback for non-Capacitor and for overlays
|
||||
// closing without a keyboard transition.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || typeof window === 'undefined') return;
|
||||
const handleIntent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ open?: boolean }>).detail;
|
||||
if (!detail || detail.open !== false) return;
|
||||
if (!expandedRef.current || alwaysExpandedRef.current) return;
|
||||
// Something still holds the composer open (dictation, an overlay
|
||||
// that closed the keyboard, a drag) — the fallback path handles it.
|
||||
if (busyRef.current) return;
|
||||
expandIntentRef.current = null;
|
||||
flushSync(() => {
|
||||
setExpanded(false);
|
||||
setExpandedInput(false);
|
||||
});
|
||||
};
|
||||
window.addEventListener('oc:keyboard-intent', handleIntent);
|
||||
return () => window.removeEventListener('oc:keyboard-intent', handleIntent);
|
||||
}, [isMobile, setExpandedInput]);
|
||||
|
||||
const onEditorFocus = React.useCallback(() => {
|
||||
if (!isMobile) return;
|
||||
// Focus is the only moment a soft keyboard would be presented, so it is
|
||||
// also the only moment its ABSENCE tells us a hardware one is attached.
|
||||
if (isCapacitorApp()) observeEditorFocus();
|
||||
if (blurTimerRef.current !== null) {
|
||||
window.clearTimeout(blurTimerRef.current);
|
||||
blurTimerRef.current = null;
|
||||
}
|
||||
expandIntentRef.current = null;
|
||||
setFocused(true);
|
||||
}, [isMobile]);
|
||||
|
||||
const onEditorBlur = React.useCallback(() => {
|
||||
if (!isMobile) return;
|
||||
|
||||
// Focus hold after an overlay-close restore: iOS may retract the rising
|
||||
// keyboard as the closing tap settles — take the focus right back
|
||||
// instead of accepting the blur.
|
||||
if (Date.now() < holdFocusUntilRef.current) {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
window.setTimeout(() => {
|
||||
if (Date.now() < holdFocusUntilRef.current && !editor.isFocused()) {
|
||||
editor.focus();
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
lastBlurAtRef.current = Date.now();
|
||||
|
||||
// Mobile browsers and installed PWAs share a blur race: the
|
||||
// keyboard-dismiss reflow moves composer buttons before the tap's
|
||||
// synthesized click lands, so the click misses its target. Capacitor's
|
||||
// WebView does not need the hold — but it DOES need the state committed
|
||||
// synchronously: the oc:keyboard-intent collapse arrives a few
|
||||
// milliseconds after this blur on a setTimeout(0), and React's own
|
||||
// scheduling can lose that race, leaving busyRef stale — the intent
|
||||
// handler then skips the instant collapse and the pill appears only
|
||||
// via the 250ms fallback, well after the keyboard has gone.
|
||||
if (isCapacitorApp()) {
|
||||
flushSync(() => setFocused(false));
|
||||
return;
|
||||
}
|
||||
if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current);
|
||||
// 120ms outlives the tap's synthesized click (which lands within a few
|
||||
// ms of the blur) while keeping the composer's return visually tied to
|
||||
// the keyboard dismissal.
|
||||
blurTimerRef.current = window.setTimeout(() => {
|
||||
blurTimerRef.current = null;
|
||||
setFocused(false);
|
||||
}, 120);
|
||||
}, [editorRef, isMobile]);
|
||||
|
||||
const skipNextOverlayCloseRestore = React.useCallback(() => {
|
||||
skipNextCloseRestoreRef.current = true;
|
||||
}, []);
|
||||
|
||||
const cancelOverlayCloseRestore = React.useCallback(() => {
|
||||
restoreKeyboardRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expanded,
|
||||
focused,
|
||||
overlayHostBusy,
|
||||
dictationActive,
|
||||
expand,
|
||||
onDictationActiveChange,
|
||||
onEditorFocus,
|
||||
onEditorBlur,
|
||||
skipNextOverlayCloseRestore,
|
||||
cancelOverlayCloseRestore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Pinning the composer to the visual viewport in mobile browsers.
|
||||
*
|
||||
* Capacitor has a keyboard choreography that resizes the shell, so the
|
||||
* composer stays where it belongs on its own. A mobile browser has nothing of
|
||||
* the sort: Safari pans the visual viewport over an unchanged layout instead
|
||||
* of shrinking it, so a composer positioned in normal flow ends up partly
|
||||
* off-screen or behind the keyboard. Both effects here exist to put it back,
|
||||
* and both are deliberately restricted to non-Capacitor mobile.
|
||||
*
|
||||
* Neither is verifiable from a test: they are corrections for specific WebKit
|
||||
* behaviors, and every guard in them marks a case that was observed breaking.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
export interface MobileViewportPinOptions {
|
||||
isMobile: boolean;
|
||||
/** Composer expanded to fullscreen on mobile. */
|
||||
isFullscreen: boolean;
|
||||
/** The new-session draft screen is showing. */
|
||||
isDraftScreen: boolean;
|
||||
/** The composer has focus, i.e. the keyboard is up. */
|
||||
isFocused: boolean;
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
editorRef: React.RefObject<ComposerEditorHandle | null>;
|
||||
}
|
||||
|
||||
/** Clear every style the pin writes, returning the form to normal flow. */
|
||||
function releaseForm(form: HTMLFormElement): void {
|
||||
form.style.position = '';
|
||||
form.style.left = '';
|
||||
form.style.right = '';
|
||||
form.style.width = '';
|
||||
form.style.top = '';
|
||||
form.style.height = '';
|
||||
form.style.zIndex = '';
|
||||
form.style.background = '';
|
||||
}
|
||||
|
||||
export function useMobileViewportPin(options: MobileViewportPinOptions): void {
|
||||
const { isMobile, isFullscreen, isDraftScreen, isFocused, formRef, editorRef } = options;
|
||||
|
||||
// Fullscreen: fix the form over the whole visible viewport and track the pan.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || !isFullscreen || isCapacitorApp()) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = formRef.current;
|
||||
const editor = editorRef.current;
|
||||
if (!vv || !form) return;
|
||||
|
||||
// The form is trapped inside lower stacking contexts (the composer
|
||||
// wrapper's z-10), so it cannot out-stack the app header with z-index
|
||||
// alone — hide the header for the duration via a root class instead.
|
||||
document.documentElement.classList.add('oc-browser-kb-fullscreen');
|
||||
|
||||
const apply = () => {
|
||||
const top = Math.max(0, Math.floor(vv.offsetTop));
|
||||
// Stale-visualViewport guard: when the layout viewport is
|
||||
// keyboard-resized (interactive-widget), its clientHeight is the
|
||||
// authoritative above-keyboard height.
|
||||
const layoutHeight = document.documentElement.clientHeight;
|
||||
form.style.position = 'fixed';
|
||||
form.style.left = '0';
|
||||
form.style.right = '0';
|
||||
form.style.top = `${top}px`;
|
||||
form.style.height = `${Math.floor(Math.min(vv.height, layoutHeight - top))}px`;
|
||||
form.style.zIndex = '40';
|
||||
form.style.background = 'var(--background)';
|
||||
};
|
||||
|
||||
apply();
|
||||
vv.addEventListener('resize', apply);
|
||||
vv.addEventListener('scroll', apply);
|
||||
window.addEventListener('resize', apply);
|
||||
window.addEventListener('scroll', apply, true);
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', apply);
|
||||
vv.removeEventListener('scroll', apply);
|
||||
window.removeEventListener('resize', apply);
|
||||
window.removeEventListener('scroll', apply, true);
|
||||
document.documentElement.classList.remove('oc-browser-kb-fullscreen');
|
||||
releaseForm(form);
|
||||
// Back in flow: the browser panned for the fullscreen session and
|
||||
// will not re-reveal the still-focused field on its own, which left
|
||||
// the composer parked behind the keyboard.
|
||||
requestAnimationFrame(() => {
|
||||
if (editor?.isFocused()) {
|
||||
editor.getScrollDOM()?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [editorRef, formRef, isFullscreen, isMobile]);
|
||||
|
||||
// Draft screen with the keyboard up: anchor the normal-height composer to
|
||||
// the visible bottom. The chat screen does not need this — its own
|
||||
// focused-field reveal works there.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || isCapacitorApp()) return;
|
||||
if (!isDraftScreen || isFullscreen || !isFocused) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = formRef.current;
|
||||
if (!vv || !form) return;
|
||||
|
||||
// Keep the in-flow horizontal geometry (page paddings) while fixed.
|
||||
const rect = form.getBoundingClientRect();
|
||||
form.style.position = 'fixed';
|
||||
form.style.left = `${Math.floor(rect.left)}px`;
|
||||
form.style.width = `${Math.floor(rect.width)}px`;
|
||||
form.style.zIndex = '40';
|
||||
form.style.background = 'var(--background)';
|
||||
|
||||
// Safari's visualViewport events are unreliable mid keyboard pan (they
|
||||
// can simply not fire), so track the pan with a rAF loop instead —
|
||||
// cheap math per frame, a style write only when the value changes.
|
||||
let lastTop = Number.NaN;
|
||||
let frame = 0;
|
||||
const track = () => {
|
||||
// iOS standalone (PWA) can serve stale visualViewport metrics after
|
||||
// the keyboard rises (full pre-keyboard height, intermittently),
|
||||
// parking the form behind the keyboard. When interactive-widget
|
||||
// resizes the layout viewport, documentElement.clientHeight is the
|
||||
// true above-keyboard bottom — anchor to whichever is smaller. In
|
||||
// pan-mode browsers clientHeight stays full height, so the min
|
||||
// keeps the visual-viewport anchor there.
|
||||
const layoutBottom = document.documentElement.clientHeight;
|
||||
const vvBottom = vv.offsetTop + vv.height;
|
||||
const top = Math.max(0, Math.floor(Math.min(vvBottom, layoutBottom) - form.offsetHeight));
|
||||
if (top !== lastTop) {
|
||||
lastTop = top;
|
||||
form.style.top = `${top}px`;
|
||||
}
|
||||
frame = requestAnimationFrame(track);
|
||||
};
|
||||
track();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
releaseForm(form);
|
||||
};
|
||||
}, [formRef, isDraftScreen, isFocused, isFullscreen, isMobile]);
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type OutgoingMessageDeps,
|
||||
type OutgoingMessageInput,
|
||||
} from '../buildOutgoingMessage';
|
||||
|
||||
const attachment = (id: string) => ({ id, filename: `${id}.txt` } as unknown as AttachedFile);
|
||||
|
||||
/**
|
||||
* Resolvers with just enough behavior to observe ordering: `@agent:name`
|
||||
* names an agent, `@file:x` resolves to an attachment, `/skill` is a skill.
|
||||
*/
|
||||
const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps => ({
|
||||
parseAgentMention: (text) => {
|
||||
const match = /@agent:(\w+)\s*/.exec(text);
|
||||
return match
|
||||
? { text: text.replace(match[0], ''), agentName: match[1] }
|
||||
: { text };
|
||||
},
|
||||
extractFileMentions: (text) => {
|
||||
const attachments = [...text.matchAll(/@file:(\w+)/g)].map((m) => attachment(m[1]));
|
||||
return { text, attachments };
|
||||
},
|
||||
sanitizeAttachments: (files) => [...(files ?? [])],
|
||||
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
|
||||
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
|
||||
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageInput => ({
|
||||
queued: [],
|
||||
composerText: null,
|
||||
composerAttachments: [],
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssueContext: null,
|
||||
linkedPr: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('the composer text alone', () => {
|
||||
test('becomes the primary message', () => {
|
||||
const result = buildOutgoingMessage(input({ composerText: 'hello' }), deps());
|
||||
expect(result.primaryText).toBe('hello');
|
||||
expect(result.additionalParts).toEqual([]);
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
test('surrounding blank lines are trimmed', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: '\n\nhello\n\n' }), deps()).primaryText)
|
||||
.toBe('hello');
|
||||
});
|
||||
|
||||
test('interior blank lines are preserved', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'a\n\nb' }), deps()).primaryText)
|
||||
.toBe('a\n\nb');
|
||||
});
|
||||
|
||||
test('its attachments and resolved file mentions travel with it', () => {
|
||||
const result = buildOutgoingMessage(
|
||||
input({ composerText: 'see @file:doc', composerAttachments: [attachment('pic')] }),
|
||||
deps(),
|
||||
);
|
||||
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['pic', 'doc']);
|
||||
});
|
||||
|
||||
test('nothing at all is empty', () => {
|
||||
expect(buildOutgoingMessage(input(), deps()).isEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queued messages', () => {
|
||||
test('the oldest becomes primary and the rest follow in order', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'first' }, { content: 'second' }, { content: 'third' }],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('first');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['second', 'third']);
|
||||
});
|
||||
|
||||
test('the composer text lands after everything queued', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
composerText: 'typed now',
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['typed now']);
|
||||
});
|
||||
|
||||
test('each queued message keeps its own attachments', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [
|
||||
{ content: 'a', attachments: [attachment('one')] },
|
||||
{ content: 'b', attachments: [attachment('two')] },
|
||||
],
|
||||
}), deps());
|
||||
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['one']);
|
||||
expect(result.additionalParts[0].attachments?.map((a) => a.id)).toEqual(['two']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent mentions', () => {
|
||||
test('an agent named in the composer routes the send', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: '@agent:build do it' }), deps())
|
||||
.agentMentionName).toBe('build');
|
||||
});
|
||||
|
||||
test('the first mention wins across queued messages', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }, { content: '@agent:build b' }],
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
});
|
||||
|
||||
test('a queued mention outranks one typed later', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }],
|
||||
composerText: '@agent:build b',
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
});
|
||||
|
||||
test('no mention leaves the routing unset', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'plain' }), deps()).agentMentionName)
|
||||
.toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline comments', () => {
|
||||
test('attach to the composer text when nothing was queued', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'body',
|
||||
inlineComments: [{}, {}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('body\n[2 comments]');
|
||||
});
|
||||
|
||||
test('attach to the last authored part when messages were queued', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
composerText: 'typed',
|
||||
inlineComments: [{}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
|
||||
});
|
||||
|
||||
test('fall back to primary when the queue produced no additional parts', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'only queued' }],
|
||||
inlineComments: [{}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('only queued\n[1 comments]');
|
||||
});
|
||||
|
||||
test('no comments changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
|
||||
.toBe('body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('synthetic context', () => {
|
||||
test('a linked PR sends its instructions before its diff', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'review this',
|
||||
linkedPr: { instructions: 'how to read it', context: 'the diff' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['how to read it', 'the diff']);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
});
|
||||
|
||||
test('a linked issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedIssueContext: 'issue body',
|
||||
}), deps());
|
||||
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssueContext: 'issue body',
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['conflict note', 'issue body']);
|
||||
});
|
||||
|
||||
test('skills named inline are collected into a trailing instruction', () => {
|
||||
const result = buildOutgoingMessage(input({ composerText: 'use /deploy now' }), deps());
|
||||
expect(result.additionalParts.at(-1)).toEqual({ text: 'use: deploy', synthetic: true });
|
||||
});
|
||||
|
||||
test('skills are collected across every authored body, without duplicates', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '/deploy a' }],
|
||||
composerText: '/deploy and /audit',
|
||||
}), deps());
|
||||
expect(result.additionalParts.at(-1)?.text).toBe('use: deploy,audit');
|
||||
});
|
||||
|
||||
test('no skills means no instruction', () => {
|
||||
const result = buildOutgoingMessage(input({ composerText: 'plain text' }), deps());
|
||||
expect(result.additionalParts).toEqual([]);
|
||||
});
|
||||
|
||||
test('context alone is still worth sending', () => {
|
||||
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
test('attachments alone are worth sending', () => {
|
||||
const result = buildOutgoingMessage(
|
||||
input({ composerText: '', composerAttachments: [attachment('pic')] }),
|
||||
deps(),
|
||||
);
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full assembly order', () => {
|
||||
test('queued, then typed, then synthetic, then references, then skills', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssueContext: 'issue',
|
||||
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual([
|
||||
'q2',
|
||||
'typed /deploy',
|
||||
'synthetic',
|
||||
'issue',
|
||||
'pr-how',
|
||||
'pr-diff',
|
||||
'use: deploy',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildCommandVariables,
|
||||
canRunCommand,
|
||||
findMagicPromptCommand,
|
||||
MAGIC_PROMPT_COMMANDS,
|
||||
parseSlashCommand,
|
||||
} from '../slashCommands';
|
||||
|
||||
describe('parseSlashCommand', () => {
|
||||
test('reads a bare command', () => {
|
||||
expect(parseSlashCommand('/explore')).toEqual({ name: 'explore', argument: '' });
|
||||
});
|
||||
|
||||
test('reads a command with an argument', () => {
|
||||
expect(parseSlashCommand('/summary rate limiting'))
|
||||
.toEqual({ name: 'summary', argument: 'rate limiting' });
|
||||
});
|
||||
|
||||
test('leading whitespace is tolerated', () => {
|
||||
expect(parseSlashCommand(' /debug')).toEqual({ name: 'debug', argument: '' });
|
||||
});
|
||||
|
||||
test('the name is lowercased but the argument keeps its casing', () => {
|
||||
expect(parseSlashCommand('/Summary Rate Limiting'))
|
||||
.toEqual({ name: 'summary', argument: 'Rate Limiting' });
|
||||
});
|
||||
|
||||
test('a multi-line argument is preserved', () => {
|
||||
expect(parseSlashCommand('/craft-goal line one\nline two'))
|
||||
.toEqual({ name: 'craft-goal', argument: 'line one\nline two' });
|
||||
});
|
||||
|
||||
test('ordinary prose is not a command', () => {
|
||||
expect(parseSlashCommand('explore the code')).toBeNull();
|
||||
expect(parseSlashCommand('see src/a.ts')).toBeNull();
|
||||
expect(parseSlashCommand('')).toBeNull();
|
||||
});
|
||||
|
||||
test('a bare slash is not a command', () => {
|
||||
expect(parseSlashCommand('/')).toBeNull();
|
||||
expect(parseSlashCommand('/ ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMagicPromptCommand', () => {
|
||||
test('finds a registered command', () => {
|
||||
expect(findMagicPromptCommand('explore')?.name).toBe('explore');
|
||||
});
|
||||
|
||||
test('commands handled elsewhere are not prompt-pair commands', () => {
|
||||
// undo/redo/timeline/compact/handoff-review manipulate state or open
|
||||
// UI rather than sending a message.
|
||||
expect(findMagicPromptCommand('undo')).toBeNull();
|
||||
expect(findMagicPromptCommand('timeline')).toBeNull();
|
||||
expect(findMagicPromptCommand('compact')).toBeNull();
|
||||
});
|
||||
|
||||
test('an unknown name finds nothing', () => {
|
||||
expect(findMagicPromptCommand('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRunCommand', () => {
|
||||
const summary = findMagicPromptCommand('summary')!;
|
||||
const explore = findMagicPromptCommand('explore')!;
|
||||
|
||||
test('summarizing needs an existing conversation', () => {
|
||||
expect(canRunCommand(summary, { hasSession: true, hasDraft: false })).toBe(true);
|
||||
expect(canRunCommand(summary, { hasSession: false, hasDraft: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('most commands also run from a new-session draft', () => {
|
||||
expect(canRunCommand(explore, { hasSession: false, hasDraft: true })).toBe(true);
|
||||
expect(canRunCommand(explore, { hasSession: true, hasDraft: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('nothing runs with neither', () => {
|
||||
expect(canRunCommand(explore, { hasSession: false, hasDraft: false })).toBe(false);
|
||||
expect(canRunCommand(summary, { hasSession: false, hasDraft: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommandVariables', () => {
|
||||
test('a command without an argument contributes no variables', () => {
|
||||
expect(buildCommandVariables(findMagicPromptCommand('explore')!, ''))
|
||||
.toEqual({ visible: {}, instructions: {} });
|
||||
});
|
||||
|
||||
test('a summary topic reaches both prompts', () => {
|
||||
const variables = buildCommandVariables(findMagicPromptCommand('summary')!, 'auth');
|
||||
expect(variables.visible.topic_line).toBe(' focused on: auth');
|
||||
expect(variables.instructions.topic_block).toContain('auth');
|
||||
});
|
||||
|
||||
test('an absent summary topic leaves both slots blank, not "undefined"', () => {
|
||||
const variables = buildCommandVariables(findMagicPromptCommand('summary')!, '');
|
||||
expect(variables.visible.topic_line).toBe('');
|
||||
expect(variables.instructions.topic_block).toBe('');
|
||||
});
|
||||
|
||||
test('an idea is formatted as its own block', () => {
|
||||
const variables = buildCommandVariables(findMagicPromptCommand('craft-goal')!, 'a CLI');
|
||||
expect(variables.visible.idea_block).toBe('\n\nHere is my initial idea:\na CLI');
|
||||
});
|
||||
|
||||
test('an absent idea leaves the slot blank', () => {
|
||||
expect(buildCommandVariables(findMagicPromptCommand('schedule-task')!, '').visible.idea_block)
|
||||
.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the command table', () => {
|
||||
test('names are unique', () => {
|
||||
const names = MAGIC_PROMPT_COMMANDS.map((command) => command.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
test('every command names both prompts and a failure toast', () => {
|
||||
for (const command of MAGIC_PROMPT_COMMANDS) {
|
||||
expect(command.visiblePrompt.startsWith('session.')).toBe(true);
|
||||
expect(command.instructionsPrompt.startsWith('session.')).toBe(true);
|
||||
expect(command.errorToastKey.startsWith('chat.chatInput.toast.')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Assembling what the composer actually sends.
|
||||
*
|
||||
* A single send can carry more than what the user just typed: messages queued
|
||||
* while the previous turn ran, inline review comments, `@file` references
|
||||
* resolved to attachments, a linked GitHub issue or PR, synthetic parts from
|
||||
* conflict resolution, and an instruction naming the skills mentioned inline.
|
||||
*
|
||||
* OpenCode takes one primary message plus additional parts, so all of that has
|
||||
* to be flattened into that shape — and the flattening has rules that are easy
|
||||
* to get wrong and impossible to see when they are spread through a 400-line
|
||||
* handler. They are stated here, as a pure function over injected resolvers,
|
||||
* so the ordering can be tested rather than trusted.
|
||||
*/
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
|
||||
export interface OutgoingPart {
|
||||
text: string;
|
||||
attachments?: AttachedFile[];
|
||||
/** Synthetic parts are context for the model, not shown as user content. */
|
||||
synthetic?: boolean;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
primaryText: string;
|
||||
primaryAttachments: AttachedFile[];
|
||||
additionalParts: OutgoingPart[];
|
||||
/** The agent the first `@agent` mention routed to, if any. */
|
||||
agentMentionName?: string;
|
||||
/** True when there is nothing worth sending. */
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedInput {
|
||||
content: string;
|
||||
attachments?: AttachedFile[];
|
||||
}
|
||||
|
||||
export interface OutgoingMessageInput {
|
||||
/** Messages queued while a turn was running, oldest first. */
|
||||
queued: readonly QueuedInput[];
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** Inline review comments, appended to the user's last authored text. */
|
||||
inlineComments: readonly unknown[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssueContext: string | null;
|
||||
linkedPr: { instructions: string; context: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of assembly that depend on stores or async config, injected so the
|
||||
* assembly itself stays pure.
|
||||
*/
|
||||
export interface OutgoingMessageDeps {
|
||||
/** Strip a leading `@agent` mention and report which agent it named. */
|
||||
parseAgentMention: (text: string) => { text: string; agentName?: string };
|
||||
/** Resolve `@path` references into server-side attachments. */
|
||||
extractFileMentions: (text: string) => { text: string; attachments: AttachedFile[] };
|
||||
/** Normalize attachments for transport (server paths become file URLs). */
|
||||
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
|
||||
/** Skills named inline with `/name`. */
|
||||
collectSkillNames: (text: string) => string[];
|
||||
/** Append inline review comments to a message body. */
|
||||
appendComments: (text: string, comments: readonly unknown[]) => string;
|
||||
/** Instruction telling the model which skills the user named. */
|
||||
buildSkillInstruction: (names: string[]) => string | null;
|
||||
}
|
||||
|
||||
export function buildOutgoingMessage(
|
||||
input: OutgoingMessageInput,
|
||||
deps: OutgoingMessageDeps,
|
||||
): OutgoingMessage {
|
||||
let primaryText = '';
|
||||
let primaryAttachments: AttachedFile[] = [];
|
||||
let agentMentionName: string | undefined;
|
||||
const additionalParts: OutgoingPart[] = [];
|
||||
|
||||
const skillNames: string[] = [];
|
||||
const noteSkills = (text: string) => {
|
||||
for (const name of deps.collectSkillNames(text)) {
|
||||
if (!skillNames.includes(name)) skillNames.push(name);
|
||||
}
|
||||
};
|
||||
|
||||
/** The first agent mention encountered wins; later ones are ignored. */
|
||||
const noteAgent = (name?: string) => {
|
||||
if (!agentMentionName && name) agentMentionName = name;
|
||||
};
|
||||
|
||||
/** Run a body through mention parsing, collecting its side effects. */
|
||||
const resolve = (raw: string) => {
|
||||
const agent = deps.parseAgentMention(raw);
|
||||
noteAgent(agent.agentName);
|
||||
const mentions = deps.extractFileMentions(agent.text);
|
||||
noteSkills(mentions.text);
|
||||
return mentions;
|
||||
};
|
||||
|
||||
// Queued messages come first, in the order they were queued: the oldest
|
||||
// becomes the primary message so the turn reads chronologically.
|
||||
input.queued.forEach((queued, index) => {
|
||||
const resolved = resolve(queued.content);
|
||||
const attachments = [
|
||||
...deps.sanitizeAttachments(queued.attachments),
|
||||
...resolved.attachments,
|
||||
];
|
||||
|
||||
if (index === 0) {
|
||||
primaryText = resolved.text;
|
||||
primaryAttachments = attachments;
|
||||
return;
|
||||
}
|
||||
additionalParts.push({ text: resolved.text, attachments });
|
||||
});
|
||||
|
||||
// The composer's own text follows, becoming primary only when nothing was
|
||||
// queued ahead of it.
|
||||
if (input.composerText !== null) {
|
||||
const resolved = resolve(input.composerText.replace(/^\n+|\n+$/g, ''));
|
||||
const attachments = [
|
||||
...deps.sanitizeAttachments(input.composerAttachments),
|
||||
...resolved.attachments,
|
||||
];
|
||||
|
||||
if (input.queued.length === 0) {
|
||||
primaryText = resolved.text;
|
||||
primaryAttachments = attachments;
|
||||
} else {
|
||||
additionalParts.push({ text: resolved.text, attachments });
|
||||
}
|
||||
}
|
||||
|
||||
// Inline comments attach to the last thing the user authored, so they read
|
||||
// as a continuation of it rather than as a separate turn.
|
||||
if (input.inlineComments.length > 0) {
|
||||
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
|
||||
? additionalParts[additionalParts.length - 1]
|
||||
: null;
|
||||
if (lastAuthored) {
|
||||
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
|
||||
} else {
|
||||
primaryText = deps.appendComments(primaryText, input.inlineComments);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never user-visible content.
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssueContext) {
|
||||
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
|
||||
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
}
|
||||
|
||||
return {
|
||||
primaryText,
|
||||
primaryAttachments,
|
||||
additionalParts,
|
||||
agentMentionName,
|
||||
isEmpty: !primaryText && primaryAttachments.length === 0 && additionalParts.length === 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The composer's local slash commands.
|
||||
*
|
||||
* Most of them do the same thing: render a pair of magic prompts — one the
|
||||
* user sees, one the model is instructed with — and send them as a single
|
||||
* message. That shape was previously written out nine times as an `else if`
|
||||
* chain, so adding a command meant copying twenty lines and remembering to
|
||||
* change every string in them. Here the shape is the executor and the
|
||||
* commands are data.
|
||||
*
|
||||
* Commands that are not "send a prompt pair" (undo, redo, timeline, compact,
|
||||
* handoff-review) stay with the composer: they manipulate session state or
|
||||
* open UI rather than producing a message.
|
||||
*/
|
||||
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { MagicPromptId } from '@/lib/magicPrompts';
|
||||
|
||||
/** What a command needs before it can run. */
|
||||
export type CommandRequirement = 'session' | 'session-or-draft';
|
||||
|
||||
export interface MagicPromptCommand {
|
||||
/** The name typed after the slash. */
|
||||
name: string;
|
||||
/** Magic prompt shown to the user as their message. */
|
||||
visiblePrompt: MagicPromptId;
|
||||
/** Magic prompt attached as synthetic instructions for the model. */
|
||||
instructionsPrompt: MagicPromptId;
|
||||
/** i18n key for the toast shown when the command fails. */
|
||||
errorToastKey: I18nKey;
|
||||
requires: CommandRequirement;
|
||||
/**
|
||||
* Turn the text typed after the command name into template variables.
|
||||
* Commands without an argument omit this.
|
||||
*/
|
||||
buildVariables?: (argument: string) => {
|
||||
visible?: Record<string, string>;
|
||||
instructions?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/summary rate limiting` focuses the summary on that topic. The topic is
|
||||
* woven into the visible message as a phrase and into the instructions as a
|
||||
* directive, so both read naturally when it is absent.
|
||||
*/
|
||||
const summaryVariables = (topic: string) => ({
|
||||
visible: { topic_line: topic ? ` focused on: ${topic}` : '' },
|
||||
instructions: {
|
||||
topic_block: topic
|
||||
? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.`
|
||||
: '',
|
||||
},
|
||||
});
|
||||
|
||||
/** `/craft-goal <idea>` and `/schedule-task <idea>` seed the prompt with the idea. */
|
||||
const ideaVariables = (idea: string) => ({
|
||||
visible: { idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '' },
|
||||
});
|
||||
|
||||
export const MAGIC_PROMPT_COMMANDS: readonly MagicPromptCommand[] = [
|
||||
{
|
||||
name: 'summary',
|
||||
visiblePrompt: 'session.summary.visible',
|
||||
instructionsPrompt: 'session.summary.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.summaryFailed',
|
||||
// Summarizing needs a conversation to summarize.
|
||||
requires: 'session',
|
||||
buildVariables: summaryVariables,
|
||||
},
|
||||
{
|
||||
name: 'workspace-review',
|
||||
visiblePrompt: 'session.review.visible',
|
||||
instructionsPrompt: 'session.review.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.reviewFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
{
|
||||
name: 'plan-feature',
|
||||
visiblePrompt: 'session.plan.visible',
|
||||
instructionsPrompt: 'session.plan.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.planFeatureFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
{
|
||||
name: 'craft-goal',
|
||||
visiblePrompt: 'session.craftGoal.visible',
|
||||
instructionsPrompt: 'session.craftGoal.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.craftGoalFailed',
|
||||
requires: 'session-or-draft',
|
||||
buildVariables: ideaVariables,
|
||||
},
|
||||
{
|
||||
name: 'schedule-task',
|
||||
visiblePrompt: 'session.scheduleTask.visible',
|
||||
instructionsPrompt: 'session.scheduleTask.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.scheduleTaskFailed',
|
||||
requires: 'session-or-draft',
|
||||
buildVariables: ideaVariables,
|
||||
},
|
||||
{
|
||||
name: 'catch-up',
|
||||
visiblePrompt: 'session.catchup.visible',
|
||||
instructionsPrompt: 'session.catchup.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.catchUpFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
{
|
||||
name: 'debug',
|
||||
visiblePrompt: 'session.debug.visible',
|
||||
instructionsPrompt: 'session.debug.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.debugFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
{
|
||||
name: 'weigh',
|
||||
visiblePrompt: 'session.weigh.visible',
|
||||
instructionsPrompt: 'session.weigh.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.weighFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
{
|
||||
name: 'explore',
|
||||
visiblePrompt: 'session.explore.visible',
|
||||
instructionsPrompt: 'session.explore.instructions',
|
||||
errorToastKey: 'chat.chatInput.toast.exploreFailed',
|
||||
requires: 'session-or-draft',
|
||||
},
|
||||
];
|
||||
|
||||
const COMMANDS_BY_NAME = new Map(MAGIC_PROMPT_COMMANDS.map((command) => [command.name, command]));
|
||||
|
||||
export interface ParsedSlashCommand {
|
||||
name: string;
|
||||
/** Everything typed after the command name, trimmed. */
|
||||
argument: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the leading slash command out of a message, if there is one. Only the
|
||||
* first word counts as the command; the rest is its argument.
|
||||
*/
|
||||
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith('/')) return null;
|
||||
|
||||
const withoutSlash = trimmed.slice(1);
|
||||
const name = withoutSlash.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
argument: withoutSlash.slice(name.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The prompt-pair command for `name`, or null when it is not one. */
|
||||
export function findMagicPromptCommand(name: string): MagicPromptCommand | null {
|
||||
return COMMANDS_BY_NAME.get(name) ?? null;
|
||||
}
|
||||
|
||||
/** Whether the current session state satisfies the command's requirement. */
|
||||
export function canRunCommand(
|
||||
command: MagicPromptCommand,
|
||||
state: { hasSession: boolean; hasDraft: boolean },
|
||||
): boolean {
|
||||
return command.requires === 'session'
|
||||
? state.hasSession
|
||||
: state.hasSession || state.hasDraft;
|
||||
}
|
||||
|
||||
/** The template variables for both prompts of a command invocation. */
|
||||
export function buildCommandVariables(
|
||||
command: MagicPromptCommand,
|
||||
argument: string,
|
||||
): { visible: Record<string, string>; instructions: Record<string, string> } {
|
||||
const built = command.buildVariables?.(argument) ?? {};
|
||||
return {
|
||||
visible: built.visible ?? {},
|
||||
instructions: built.instructions ?? {},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user