feat(mobile): mobile app navigation rework and beta-feedback closeout (#2561)

Navigation model rebuilt around two full-width drawers and a minimal
header (sessions / title-switcher / usage ring / workspace):

- Left sessions drawer: cross-project tree with live status indicators,
  swipe actions on sessions (rename/archive/delete) and on group headers
  (project edit / two-step close, worktree delete), reorder-only edit
  mode with collapsible project cards and draggable worktrees, app-level
  footer (connected instance, settings, pending web update).
- Right workspace drawer: Changes / Files / Terminal / Notes / MCP as
  pill tabs (inactive tabs icon-only); panes stay mounted once visited.
  The full desktop file editor serves the Files tab; read/skill tool taps
  in chat open the file there at the requested line.
- Header session switcher on title tap: 10 cross-project recents with
  live busy/attention indicators and project · branch metadata; the
  usage ring opens a metadata overlay with an explicit loading state.
- The overflow menu is gone on phones (its destinations moved into the
  drawers); iPad keeps it until its dedicated layout pass.

Correctness and continuity:

- /auth/session answers bearer-first, so a stale WebView cookie can no
  longer mask a revoked device token; cold launches classify failures
  fast and land on an explicit connect screen.
- Authoritative session snapshots raise frozen ordering baselines and
  stale live ranks — recents stay truthful after the app slept.
- Cold launches reopen the last active session per instance (persisted
  pointer, confirmed against a sessions snapshot; a user-opened draft
  clears it), with a logo hold instead of a draft flash.

Also: collapsed pill composer gains the stop control; chat tool rows
share one 36px rhythm; Task subtool rows truncate; larger bottom safe
area so the composer clears big-screen corner radii; Capacitor build
hides About/Update (store updates apply there); widgets link to the
sessions drawer with a list icon; MobileApp split into focused modules;
five mobile-surface detectors unified; translucent borders normalized to
70%; all new strings translated across the 10 locales.

iPad and foldable layouts are intentionally untouched - separate next version PR.
This commit is contained in:
Bohdan Triapitsyn
2026-08-01 21:16:36 +03:00
committed by GitHub
parent ea8cc5d7b0
commit 86ef96302d
69 changed files with 5006 additions and 4291 deletions
@@ -0,0 +1,29 @@
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
className={cn(
'absolute inset-y-0 z-30 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>
);
File diff suppressed because it is too large Load Diff
@@ -42,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
@@ -541,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}
@@ -596,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,301 @@
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';
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 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 (isScanning || isBusy) return;
conn.setError(null);
setIsScanning(true);
try {
const result = await scanConnectionQr();
switch (result.status) {
case 'ok':
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':
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 {
setIsScanning(false);
}
}, [conn, isBusy, isScanning, t]);
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
event.preventDefault();
void conn.submitPassword(password);
}, [conn, password]);
const cancelPassword = React.useCallback(() => {
setPassword('');
conn.cancelPassword();
}, [conn]);
return (
<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',
)}
+56 -241
View File
@@ -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,32 +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, isBinaryFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
import { runtimeFetch } from '@/lib/runtime-fetch';
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 => {
@@ -75,17 +68,15 @@ const formatFileSize = (size?: number): string => {
return '';
};
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[]>([]);
@@ -94,11 +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 [imageSrc, setImageSrc] = React.useState('');
const [fileError, setFileError] = React.useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const [binaryBlocked, setBinaryBlocked] = React.useState(false);
const directoryLoadRequestIdRef = React.useRef(0);
React.useEffect(() => {
@@ -170,119 +156,64 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
};
}, [files, query, route]);
React.useEffect(() => {
if (route.type !== 'file') return;
setFileContent('');
setImageSrc('');
setFileError(null);
setBinaryBlocked(false);
if (isImageFile(route.path) && !isSvgFile(route.path)) {
let cancelled = false;
let objectUrl = '';
setIsLoadingFile(true);
void runtimeFetch('/api/fs/raw', { query: { path: route.path, directory: root || undefined } })
.then(async (response) => {
if (!response.ok) throw new Error(t('filesView.error.readFileFailed'));
objectUrl = URL.createObjectURL(await response.blob());
if (cancelled) {
URL.revokeObjectURL(objectUrl);
objectUrl = '';
return;
}
setImageSrc(objectUrl);
})
.catch((error) => {
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
})
.finally(() => {
if (!cancelled) setIsLoadingFile(false);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}
// Never load PDF/office/archives/etc. as UTF-8 text — that path can corrupt originals
// if a future write path is added, and it shows gibberish in the viewer.
if (isBinaryFile(route.path) || isPdfFile(route.path)) {
setBinaryBlocked(true);
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;
if (looksLikeBinaryText(result.content)) {
setBinaryBlocked(true);
setFileContent('');
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, root, 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}
imageSrc={imageSrc}
error={fileError}
isLoading={isLoadingFile}
binaryBlocked={binaryBlocked}
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>
);
}
@@ -353,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}
@@ -383,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' }}
>
@@ -408,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}
@@ -423,122 +354,6 @@ const MobileSearchResults: React.FC<{
);
};
const MobileFileDetail: React.FC<{
path: string;
content: string;
imageSrc: string;
error: string | null;
isLoading: boolean;
binaryBlocked: boolean;
onBack: () => void;
onCopyPath: () => void;
onCopyContent: () => void;
}> = ({ path, content, imageSrc, error, isLoading, binaryBlocked, onBack, onCopyPath, onCopyContent }) => {
const { t } = useI18n();
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) && !binaryBlocked ? (
<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 ? (
<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>
) : binaryBlocked ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<div className="typography-ui-header text-foreground">{t('filesView.editor.cannotPreviewBinary')}</div>
<div className="max-w-sm typography-ui text-muted-foreground">{t('filesView.editor.binaryFileDescription')}</div>
</div>
) : (
<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,220 @@
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;
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,
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;
return createPortal(
<section
ref={surfaceRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
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)',
// 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 slide 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>,
rootRef.current,
);
};
+207
View File
@@ -0,0 +1,207 @@
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 type MobileHeaderSurfaceShortcuts = {
activePanel: 'files' | 'changes' | null;
changesDirty: boolean;
onToggleFiles: () => void;
onToggleChanges: () => void;
};
export const MobileHeader: React.FC<{
onOpenSessions: () => void;
/** iPad only for now: the legacy overflow menu. Phones distribute its items
across the sessions drawer footer and the workspace drawer tabs. */
onOpenMenu?: () => void;
/** Phone only: opens the right workspace drawer (Changes / Files / Terminal). */
onOpenWorkspace?: () => void;
/** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */
surfaceShortcuts?: MobileHeaderSurfaceShortcuts;
}> = ({ onOpenSessions, onOpenMenu, onOpenWorkspace, surfaceShortcuts }) => {
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]);
const handleOpenMenu = React.useCallback(() => {
setMetadataOpen(false);
setSwitcherOpen(false);
onOpenMenu?.();
}, [onOpenMenu]);
// 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="flex min-w-0 flex-1 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"
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>
<MobileSessionMetadataButton
open={metadataOpen}
onOpenChange={handleMetadataOpenChange}
currentSessionId={currentSessionId}
effectiveDirectory={effectiveDirectory}
isNewSessionDraftOpen={isNewSessionDraftOpen}
/>
{surfaceShortcuts ? (
<>
<button
type="button"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'files'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.files')}
aria-pressed={surfaceShortcuts.activePanel === 'files'}
onClick={surfaceShortcuts.onToggleFiles}
style={{ touchAction: 'manipulation' }}
>
<Icon name="file-text" className="size-5" />
</button>
<button
type="button"
className={cn(
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'changes'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.changes')}
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
onClick={surfaceShortcuts.onToggleChanges}
style={{ touchAction: 'manipulation' }}
>
<Icon name="git-branch" className="size-5" />
{surfaceShortcuts.changesDirty ? (
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
) : null}
</button>
</>
) : null}
{onOpenMenu ? (
<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.openMenuAria')}
onClick={handleOpenMenu}
style={{ touchAction: 'manipulation' }}
>
<Icon name="more-2" className="size-5" />
</button>
) : null}
{onOpenWorkspace ? (
<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>
) : null}
</div>
</header>
<MobileSessionSwitcher
open={switcherOpen}
onClose={() => setSwitcherOpen(false)}
anchorRef={titleRef}
/>
</>
);
};
@@ -0,0 +1,362 @@
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';
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 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 (isScanning) return;
setError(null);
setIsScanning(true);
try {
const result = await scanConnectionQr();
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':
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 {
setIsScanning(false);
}
}, [conn, isScanning, setError, t]);
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 (
<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>
);
};
@@ -0,0 +1,75 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n';
export type OverflowItem = {
key: 'files' | 'changes' | 'terminal' | 'mcp' | 'notes' | 'instances' | 'update' | 'settings';
icon?: IconName;
iconNode?: React.ReactNode;
label: string;
badge?: number;
onSelect: () => void;
};
export const MobileOverflowMenu: React.FC<{
open: boolean;
onClose: () => void;
items: OverflowItem[];
/** Extra viewport-right inset so the dropdown stays anchored to the
three-dots button when the iPad right sidebar shifts the header. */
rightOffset?: number;
}> = ({ open, onClose, items, rightOffset = 0 }) => {
const { t } = useI18n();
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]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
<button
type="button"
className="absolute inset-0 cursor-default"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
/>
<div
className="absolute top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/70 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
role="menu"
style={{
right: `${8 + rightOffset}px`,
animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)',
}}
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
style={{ touchAction: 'manipulation' }}
onClick={() => {
item.onSelect();
onClose();
}}
>
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
{item.badge && item.badge > 0 ? (
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
) : null}
</button>
))}
</div>
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
</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,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 { useI18n } from '@/lib/i18n';
import { isIPadApp } from '@/lib/platform';
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 IPAD_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);
// iPad: a phone-width sheet stretched across the whole chat column looks
// broken — render a popover anchored to the metadata button instead.
const isIPad = React.useMemo(() => isIPadApp(), []);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [ipadAnchorLeft, 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 || !isIPad || !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 - IPAD_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, isIPad, open, shouldRender]);
const ipadPopover = isIPad && ipadAnchorLeft !== 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',
ipadPopover ? '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))',
...(ipadPopover
? {
top: 8,
left: ipadAnchorLeft ?? 8,
width: `min(${IPAD_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,188 @@
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 { 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;
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);
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 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(
'mx-3 mt-2 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',
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))',
}}
>
<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
-307
View File
@@ -1,307 +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, leave Escape available to nested content instead of dismissing the surface. */
disableEscapeDismiss?: 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,
disableEscapeDismiss = 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' && !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]);
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}
>
{disableSwipeDismiss ? (
<div className="h-3" />
) : (
<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,274 @@
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>
);
};
/** Full-width right drawer with the phone workspace surfaces as tabs
(Changes / Files / Terminal / Notes / MCP). Slides in from the right edge;
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;
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings }) => {
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;
const previousOverflow = document.body.style.overflow;
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 () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
if (!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" /> },
];
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',
}}
>
<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>
</section>,
rootRef.current,
);
};
+4 -4
View File
@@ -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':
+89
View File
@@ -0,0 +1,89 @@
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;
/** 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) {
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(IPAD_SIDEBAR_MAX_WIDTH, 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(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
), []);
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';
+38 -12
View File
@@ -844,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);
@@ -976,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: {
@@ -1019,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);
@@ -1129,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
@@ -1163,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) {
@@ -1186,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';
};
+421
View File
@@ -0,0 +1,421 @@
import React from 'react';
/** 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');
}
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', () => {
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();
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());
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]);
};
+16
View File
@@ -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);
};
+4
View File
@@ -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();
+85
View File
@@ -0,0 +1,85 @@
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
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;
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;
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,128 +0,0 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
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, lifecycle-ranked with timestamp fallback. 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;
/** Top-level sessions across all projects in shared display order. */
const orderedTopLevelSessions = (): Session[] => {
const pinnedSessionIds = useSessionPinnedStore.getState().ids;
const sessionOrderRanks = useSessionOrderingStore.getState().rankById;
return useGlobalSessionsStore
.getState()
.activeSessions.filter((session) => parentIdOf(session) === null)
.slice()
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
};
/**
* 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]);
};
@@ -771,7 +771,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
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]);
@@ -48,7 +48,6 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
// useMessageStore removed — messages now come from sync system
@@ -2482,8 +2481,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
newSessionDraftOpen={newSessionDraftOpen}
hasContent={Boolean(hasContent)}
isVSCode={isVSCode}
canAbort={canAbort}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
stopIconSizeClass={stopIconSizeClass}
theme={currentTheme}
onExpand={mobileShell.expand}
onApplySuggestion={applyAssistSuggestion}
@@ -2493,6 +2494,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onOpenPrPicker={openPrPicker}
onOpenAttachSheet={openMobileAttachSheet}
onStartDictation={toggleDictation}
onAbort={handleAbort}
/>
) : (
<>
@@ -2569,7 +2571,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onClose={closeAutocomplete}
/>
{/* Positioning context for the dictation overlay: covers the
text area + footer exactly, excluding MobileSessionStatusBar. */}
text area + footer exactly. */}
<div className={cn('relative flex flex-col', isComposerExpanded && 'flex-1 min-h-0')}>
<div className={cn("overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
{isMobile ? (
@@ -2704,10 +2706,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
) : null}
</div>
{/* Mobile session panel: slide-up overlay toggled by
MobileSessionPanelTrigger. Mounted outside the pill
conditional so the pill's trigger works too. */}
{isMobile && <MobileSessionStatusBar />}
{/* Hidden host for the model/agent/variant bottom sheets. Kept
outside the pill conditional so an open panel survives (and
stays visible over) the collapsed composer. */}
@@ -1,18 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./MobileSessionStatusBar.tsx', import.meta.url), 'utf8');
describe('MobileSessionStatusBar hidden work', () => {
test('does not mount session grouping and project derivation while the panel is closed', () => {
const wrapperStart = source.indexOf('export const MobileSessionStatusBar');
const openPanelStart = source.indexOf('const MobileSessionStatusOpenPanel');
const closedGuard = source.indexOf('if (!isMobile || !open) return null;', wrapperStart);
const openPanelMount = source.indexOf('<MobileSessionStatusOpenPanel', wrapperStart);
expect(openPanelStart).toBeGreaterThan(-1);
expect(closedGuard).toBeGreaterThan(wrapperStart);
expect(openPanelMount).toBeGreaterThan(closedGuard);
expect(source.indexOf('useSessionGrouping(', openPanelStart)).toBeLessThan(wrapperStart);
});
});
@@ -1,611 +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 { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
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';
_runningChildrenCount?: number;
}
// 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 pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
const parentChildMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id));
for (const session of sessions) {
const parentID = (session as { parentID?: string }).parentID;
if (parentID && allIds.has(parentID)) {
const children = map.get(parentID);
if (children) children.push(session);
else map.set(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 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 ordered = topLevel.map((session): SessionWithStatus => {
const statusType = getStatusType(session.id);
const runningChildrenCount = (parentChildMap.get(session.id) ?? [])
.filter((child) => getStatusType(child.id) !== 'idle')
.length;
return {
...session,
_statusType: statusType,
_runningChildrenCount: runningChildrenCount,
};
});
const compare = (a: Session, b: Session) => (
compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)
);
return ordered.sort(compare);
}, [sessions, getStatusType, parentChildMap, pinnedSessionIds, sessionOrderRanks]);
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(
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>
);
};
const MobileSessionStatusOpenPanel: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
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(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) => compareSessionsByLifecycleOrder(
a,
b,
useSessionPinnedStore.getState().ids,
useSessionOrderingStore.getState().rankById,
))[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]);
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>
);
};
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = (props) => {
const isMobile = useUIStore((state) => state.isMobile);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
if (!isMobile || !open) return null;
return <MobileSessionStatusOpenPanel {...props} />;
};
@@ -305,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")}>
@@ -19,7 +19,6 @@ import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { ModelControls } from '../../ModelControls';
import { MobileSessionPanelTrigger } from '../../MobileSessionStatusBar';
import { ComposerActionButtons } from './ComposerActionButtons';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
import { FocusModeButton } from './FocusModeButton';
@@ -124,10 +123,6 @@ export function ComposerFooter(props: ComposerFooterProps) {
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
@@ -11,15 +11,13 @@
* the pill grow into its place.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { StopIcon } from '@/components/icons/StopIcon';
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { Theme } from '@/types/theme';
import { MobileSessionPanelTrigger } from '../../MobileSessionStatusBar';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
export interface MobilePillComposerProps {
@@ -29,8 +27,10 @@ export interface MobilePillComposerProps {
newSessionDraftOpen: boolean;
hasContent: boolean;
isVSCode: boolean;
canAbort: boolean;
footerIconButtonClass: string;
iconSizeClass: string;
stopIconSizeClass: string;
theme: Theme;
onExpand: () => void;
onApplySuggestion: (text: string) => void;
@@ -40,6 +40,7 @@ export interface MobilePillComposerProps {
onOpenPrPicker: () => void;
onOpenAttachSheet: () => void;
onStartDictation: () => void;
onAbort: () => void;
}
export function MobilePillComposer(props: MobilePillComposerProps) {
@@ -51,8 +52,10 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
newSessionDraftOpen,
hasContent,
isVSCode,
canAbort,
footerIconButtonClass,
iconSizeClass,
stopIconSizeClass,
theme: currentTheme,
onExpand,
onApplySuggestion,
@@ -62,6 +65,7 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onOpenPrPicker,
onOpenAttachSheet,
onStartDictation,
onAbort,
} = props;
return (
@@ -83,10 +87,6 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
>
<MobileSessionPanelTrigger
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
@@ -125,6 +125,33 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
{/* Same visibility rule as the full composer's stop control:
while a turn is running the stop button takes the mic's
end slot and the mic shifts one slot left. Instant swap
no shape animation (WKWebView). */}
{canAbort ? (
<button
type="button"
className={cn(footerIconButtonClass, 'text-[var(--status-error)] hover:text-[var(--status-error)]')}
// The pill shows only while the keyboard is down — the
// tap must abort in place, never focus/expand the
// composer or raise the keyboard.
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
}
}}
onClick={(event) => {
event.stopPropagation();
onAbort();
}}
title={t('chat.chatInput.actions.stopGeneratingAria')}
aria-label={t('chat.chatInput.actions.stopGeneratingAria')}
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
) : null}
</div>
{/* New-session button: fades/shrinks away when the draft is
already open, letting the pill expand into its place. */}
@@ -1,4 +1,5 @@
import React from 'react';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
@@ -573,6 +574,7 @@ const StaticToolRowInner: React.FC<{
const icon = getToolIcon(toolName);
const isReadGroup = toolName.toLowerCase() === 'read';
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const skills = useSkillsStore((state) => state.skills);
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
@@ -634,6 +636,21 @@ const StaticToolRowInner: React.FC<{
return;
}
// Dedicated mobile app: stage the same pending file focus/navigation
// desktop uses, then surface the Files pane (workspace drawer tab),
// which consumes it. Desktop grant flows don't apply here.
if (mobileActions) {
const uiStore = useUIStore.getState();
const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath);
if (offset && Number.isFinite(offset)) {
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
} else {
uiStore.openContextFile(contextDirectory, absolutePath);
}
mobileActions.openFiles();
return;
}
if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) {
void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => {
const uiStore = useUIStore.getState();
@@ -654,7 +671,7 @@ const StaticToolRowInner: React.FC<{
return;
}
uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]);
}, [currentDirectory, mobileActions, runtime]);
const normalizedToolName = toolName.toLowerCase();
const isSearchGroup = normalizedToolName === 'grep'
@@ -667,8 +684,11 @@ const StaticToolRowInner: React.FC<{
return (
<div
// oc-static-tool-row: on touch devices mobile.css raises this to the
// same 36px floor the [role="button"] expandable/reasoning rows get,
// so static and expandable rows have identical rhythm.
className={cn(
'flex w-full items-center gap-x-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0'
'oc-static-tool-row flex w-full items-center gap-x-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0'
)}
>
<div className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
@@ -1,5 +1,6 @@
import React from 'react';
import { useMobileAppActions } from '@/apps/mobileAppContext';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils';
@@ -1152,7 +1153,11 @@ const TaskSummaryEntryRow = React.memo(({
return (
<ToolRevealOnMount animate={animateTailText} wipe>
<div className={cn('flex gap-2 min-w-0 w-full', isMobile ? 'items-start' : 'items-center')}>
{/* Single-line rows everywhere: the old mobile break-words mode
wrapped long shell commands into a hanging column and floated
the icon to the top of the block. Errors still wrap they must
stay readable. */}
<div className={cn('flex gap-2 min-w-0 w-full', status === 'error' && isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
@@ -1175,10 +1180,7 @@ const TaskSummaryEntryRow = React.memo(({
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}
className="typography-meta flex-1 min-w-0 truncate text-muted-foreground/70"
style={{ color: 'var(--tools-description)' }}
title={label}
>
@@ -1587,6 +1589,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}) => {
const { t } = useI18n();
const runtime = React.useContext(RuntimeAPIContext);
const mobileActions = useMobileAppActions();
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
@@ -1694,6 +1697,9 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
return;
}
useUIStore.getState().openContextFileAtLine(currentDirectory, absolutePath, line ?? 1, 1);
// Dedicated mobile app: the pending file navigation is consumed by
// the FilesView pane — surface it (workspace drawer Files tab).
mobileActions?.openFiles();
};
const openEntryDiff = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
@@ -2421,13 +2427,16 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
<div className={cn('flex gap-1.5', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}>
{}
<div
className="relative h-3.5 w-3.5 flex-shrink-0 cursor-pointer"
// h-5 matches StaticToolRow's icon column, so expandable
// and static rows come out the same height (the 14px
// icon alone left these rows ~2px shorter).
className="relative h-5 w-3.5 flex-shrink-0 cursor-pointer"
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
>
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
'absolute inset-0 flex items-center justify-center transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
)}
+1 -3
View File
@@ -10,7 +10,6 @@ export const iconSpriteData = {
"alert": `<path d="M12.8659 3.00017L22.3922 19.5002C22.6684 19.9785 22.5045 20.5901 22.0262 20.8662C21.8742 20.954 21.7017 21.0002 21.5262 21.0002H2.47363C1.92135 21.0002 1.47363 20.5525 1.47363 20.0002C1.47363 19.8246 1.51984 19.6522 1.60761 19.5002L11.1339 3.00017C11.41 2.52187 12.0216 2.358 12.4999 2.63414C12.6519 2.72191 12.7782 2.84815 12.8659 3.00017ZM4.20568 19.0002H19.7941L11.9999 5.50017L4.20568 19.0002ZM10.9999 16.0002H12.9999V18.0002H10.9999V16.0002ZM10.9999 9.00017H12.9999V14.0002H10.9999V9.00017Z" fill="currentColor"/>`,
"align-justify": `<path d="M3 4H21V6H3V4ZM3 19H21V21H3V19ZM3 14H21V16H3V14ZM3 9H21V11H3V9Z" fill="currentColor"/>`,
"apple": `<path d="M15.778 8.20793C15.3053 8.1711 14.7974 8.28434 14.0197 8.58067C14.085 8.55577 13.2775 8.87173 13.0511 8.95077C12.5494 9.12593 12.1364 9.22198 11.6734 9.22198C11.2151 9.22198 10.7925 9.13042 10.3078 8.96683C10.1524 8.91441 9.99616 8.8564 9.80283 8.7809C9.71993 8.74852 9.41997 8.62947 9.3544 8.60379C8.70626 8.34996 8.34154 8.25434 8.03885 8.26181C6.88626 8.2765 5.79557 8.9421 5.16246 10.0442C3.87037 12.2875 4.58583 16.3428 6.47459 19.075C7.4802 20.5189 8.03062 21.035 8.25199 21.0279C8.4743 21.0183 8.63777 20.9713 9.03567 20.8026C9.11485 20.7689 9.11485 20.7689 9.202 20.7317C10.2077 20.3032 10.9118 20.114 11.9734 20.114C12.9944 20.114 13.6763 20.2997 14.6416 20.7159C14.7302 20.7542 14.7302 20.7542 14.8097 20.7884C15.2074 20.9588 15.3509 20.9962 15.6016 20.9902C15.9591 20.9846 16.4003 20.5726 17.3791 19.1362C17.6471 18.7447 17.884 18.3333 18.0895 17.9168C17.9573 17.8077 17.826 17.6917 17.6975 17.5693C16.4086 16.3408 15.6114 14.6845 15.5895 12.6391C15.5756 11.0186 16.1057 9.61487 16.999 8.45797C16.6293 8.3142 16.2216 8.23805 15.778 8.20793ZM15.9334 6.21398C16.6414 6.26198 18.6694 6.47798 19.9894 8.40998C19.8814 8.46998 17.5654 9.81397 17.5894 12.622C17.6254 15.982 20.5294 17.098 20.5654 17.11C20.5414 17.194 20.0974 18.706 19.0294 20.266C18.1054 21.622 17.1454 22.966 15.6334 22.99C14.1454 23.026 13.6654 22.114 11.9734 22.114C10.2694 22.114 9.74138 22.966 8.33738 23.026C6.87338 23.074 5.76938 21.562 4.83338 20.218C2.92538 17.458 1.47338 12.442 3.42938 9.04597C4.40138 7.35397 6.12938 6.28598 8.01338 6.26198C9.44138 6.22598 10.7974 7.22198 11.6734 7.22198C12.5374 7.22198 14.0854 6.06998 15.9334 6.21398ZM14.7934 4.38998C14.0134 5.32598 12.7414 6.05798 11.5054 5.96198C11.3374 4.68998 11.9614 3.35798 12.6814 2.52998C13.4854 1.59398 14.8294 0.897976 15.9454 0.849976C16.0894 2.14598 15.5734 3.45398 14.7934 4.38998Z" fill="currentColor"/>`,
"apps-2-ai": `<path d="M2.5 7C2.5 9.48528 4.51472 11.5 7 11.5C9.48528 11.5 11.5 9.48528 11.5 7C11.5 4.51472 9.48528 2.5 7 2.5C4.51472 2.5 2.5 4.51472 2.5 7ZM2.5 17C2.5 19.4853 4.51472 21.5 7 21.5C9.48528 21.5 11.5 19.4853 11.5 17C11.5 14.5147 9.48528 12.5 7 12.5C4.51472 12.5 2.5 14.5147 2.5 17ZM12.5 17C12.5 19.4853 14.5147 21.5 17 21.5C19.4853 21.5 21.5 19.4853 21.5 17C21.5 14.5147 19.4853 12.5 17 12.5C14.5147 12.5 12.5 14.5147 12.5 17ZM9.5 7C9.5 8.38071 8.38071 9.5 7 9.5C5.61929 9.5 4.5 8.38071 4.5 7C4.5 5.61929 5.61929 4.5 7 4.5C8.38071 4.5 9.5 5.61929 9.5 7ZM9.5 17C9.5 18.3807 8.38071 19.5 7 19.5C5.61929 19.5 4.5 18.3807 4.5 17C4.5 15.6193 5.61929 14.5 7 14.5C8.38071 14.5 9.5 15.6193 9.5 17ZM19.5 17C19.5 18.3807 18.3807 19.5 17 19.5C15.6193 19.5 14.5 18.3807 14.5 17C14.5 15.6193 15.6193 14.5 17 14.5C18.3807 14.5 19.5 15.6193 19.5 17ZM17.5252 11.155L17.8026 10.5186C18.297 9.38398 19.1876 8.48059 20.2988 7.98638L21.1534 7.60631C21.6155 7.4008 21.6155 6.7284 21.1534 6.52289L20.3467 6.16406C19.2068 5.65713 18.3002 4.72031 17.8143 3.54712L17.5295 2.85945C17.3309 2.38018 16.669 2.38018 16.4705 2.85945L16.1856 3.54712C15.6997 4.72031 14.7932 5.65713 13.6534 6.16406L12.8466 6.52289C12.3845 6.7284 12.3845 7.4008 12.8466 7.60631L13.7011 7.98638C14.8124 8.48059 15.7029 9.38398 16.1974 10.5186L16.4748 11.155C16.6778 11.6209 17.3222 11.6209 17.5252 11.155Z" fill="currentColor"/>`,
"archive": `<path d="M3 10H2V4.00293C2 3.44903 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.43788 22 4.00293V10H21V20.0015C21 20.553 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5525 3 20.0015V10ZM19 10H5V19H19V10ZM4 5V8H20V5H4ZM9 12H15V14H9V12Z" fill="currentColor"/>`,
"archive-stack": `<path d="M4 5H20V3H4V5ZM20 9H4V7H20V9ZM3 11H10V13H14V11H21V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V11ZM16 13V15H8V13H5V19H19V13H16Z" fill="currentColor"/>`,
"arrow-down": `<path d="M13.0001 16.1716L18.3641 10.8076L19.7783 12.2218L12.0001 20L4.22192 12.2218L5.63614 10.8076L11.0001 16.1716V4H13.0001V16.1716Z" fill="currentColor"/>`,
@@ -18,7 +17,6 @@ export const iconSpriteData = {
"arrow-go-back": `<path d="M5.82843 6.99955L8.36396 9.53509L6.94975 10.9493L2 5.99955L6.94975 1.0498L8.36396 2.46402L5.82843 4.99955H13C17.4183 4.99955 21 8.58127 21 12.9996C21 17.4178 17.4183 20.9996 13 20.9996H4V18.9996H13C16.3137 18.9996 19 16.3133 19 12.9996C19 9.68584 16.3137 6.99955 13 6.99955H5.82843Z" fill="currentColor"/>`,
"arrow-go-forward": `<path d="M18.1716 6.99955H11C7.68629 6.99955 5 9.68584 5 12.9996C5 16.3133 7.68629 18.9996 11 18.9996H20V20.9996H11C6.58172 20.9996 3 17.4178 3 12.9996C3 8.58127 6.58172 4.99955 11 4.99955H18.1716L15.636 2.46402L17.0503 1.0498L22 5.99955L17.0503 10.9493L15.636 9.53509L18.1716 6.99955Z" fill="currentColor"/>`,
"arrow-left": `<path d="M7.82843 10.9999H20V12.9999H7.82843L13.1924 18.3638L11.7782 19.778L4 11.9999L11.7782 4.22168L13.1924 5.63589L7.82843 10.9999Z" fill="currentColor"/>`,
"arrow-left-long": `<path d="M22.0003 13.0001L22.0004 11.0002L5.82845 11.0002L9.77817 7.05044L8.36396 5.63623L2 12.0002L8.36396 18.3642L9.77817 16.9499L5.8284 13.0002L22.0003 13.0001Z" fill="currentColor"/>`,
"arrow-left-right": `<path d="M16.0503 12.0498L21 16.9996L16.0503 21.9493L14.636 20.5351L17.172 17.9988L4 17.9996V15.9996L17.172 15.9988L14.636 13.464L16.0503 12.0498ZM7.94975 2.0498L9.36396 3.46402L6.828 5.9988L20 5.99955V7.99955L6.828 7.9988L9.36396 10.5351L7.94975 11.9493L3 6.99955L7.94975 2.0498Z" fill="currentColor"/>`,
"arrow-left-s": `<path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z" fill="currentColor"/>`,
"arrow-right": `<path d="M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z" fill="currentColor"/>`,
@@ -176,6 +174,7 @@ export const iconSpriteData = {
"pencil": `<path d="M15.7279 9.57627L14.3137 8.16206L5 17.4758V18.89H6.41421L15.7279 9.57627ZM17.1421 8.16206L18.5563 6.74785L17.1421 5.33363L15.7279 6.74785L17.1421 8.16206ZM7.24264 20.89H3V16.6473L16.435 3.21231C16.8256 2.82179 17.4587 2.82179 17.8492 3.21231L20.6777 6.04074C21.0682 6.43126 21.0682 7.06443 20.6777 7.45495L7.24264 20.89Z" fill="currentColor"/>`,
"pencil-ai": `<path d="M16.4356 3.21188C16.8261 2.82185 17.4592 2.82157 17.8496 3.21188L20.6777 6.04099C21.0681 6.43152 21.0682 7.06457 20.6777 7.45505L7.2422 20.8896H3.00001V16.6475L16.4356 3.21188ZM5.00001 17.4756V18.8896H6.41407L15.7276 9.57615L14.3135 8.16208L5.00001 17.4756ZM4.5293 1.3193C4.70583 0.893505 5.29418 0.893508 5.47071 1.3193L5.72364 1.93063C6.15555 2.97342 6.96155 3.80613 7.97462 4.2568L8.69239 4.57614C9.10267 4.75896 9.10262 5.35616 8.69239 5.53903L7.93263 5.87692C6.94497 6.3162 6.15339 7.11943 5.71387 8.1279L5.4668 8.69334C5.28636 9.10747 4.71366 9.10747 4.53321 8.69334L4.28614 8.1279C3.84661 7.11943 3.05506 6.3162 2.06739 5.87692L1.30762 5.53903C0.897483 5.35617 0.897435 4.75896 1.30762 4.57614L2.0254 4.2568C3.03845 3.80614 3.84446 2.97344 4.27637 1.93063L4.5293 1.3193ZM15.7276 6.74802L17.1426 8.16208L18.5567 6.74802L17.1426 5.33395L15.7276 6.74802Z" fill="currentColor"/>`,
"pencil-ai-2": `<path d="M18.5293 15.3193C18.7058 14.8934 19.2942 14.8934 19.4707 15.3193L19.7236 15.9307C20.1556 16.9735 20.9615 17.8062 21.9746 18.2568L22.6914 18.5762C23.1022 18.7589 23.1022 19.3564 22.6914 19.5391L21.9326 19.877C20.9449 20.3163 20.1534 21.1194 19.7139 22.1279L19.4668 22.6934C19.2863 23.1075 18.7136 23.1075 18.5332 22.6934L18.2861 22.1279C17.8466 21.1194 17.0551 20.3163 16.0674 19.877L15.3076 19.5391C14.8974 19.3562 14.8974 18.759 15.3076 18.5762L16.0254 18.2568C17.0385 17.8062 17.8444 16.9735 18.2764 15.9307L18.5293 15.3193ZM16.4346 3.21193C16.8251 2.82141 17.4591 2.82141 17.8496 3.21193L20.6777 6.04103C21.0681 6.43157 21.0682 7.06464 20.6777 7.45509L7.24219 20.8897H3V16.6475L16.4346 3.21193ZM5 17.4756V18.8897H6.41406L15.7275 9.57618L14.3135 8.16212L5 17.4756ZM15.7275 6.74806L17.1426 8.16212L18.5566 6.74806L17.1426 5.334L15.7275 6.74806Z" fill="currentColor"/>`,
"pencil-ruler-2": `<path d="M7.05033 14.1213L4.929 16.2427L7.75743 19.0711L19.0711 7.75737L16.2427 4.92894L14.1214 7.05026L15.5356 8.46448L14.1214 9.87869L12.7072 8.46448L11.293 9.87869L12.7072 11.2929L11.293 12.7071L9.87875 11.2929L8.46454 12.7071L9.87875 14.1213L8.46454 15.5355L7.05033 14.1213ZM16.9498 2.80762L21.1925 7.05026C21.583 7.44079 21.583 8.07395 21.1925 8.46448L8.46454 21.1924C8.07401 21.5829 7.44085 21.5829 7.05033 21.1924L2.80768 16.9498C2.41716 16.5592 2.41716 15.9261 2.80768 15.5355L15.5356 2.80762C15.9261 2.4171 16.5593 2.4171 16.9498 2.80762ZM14.1214 18.3635L15.5356 16.9493L17.7781 19.1918H19.1923V17.7776L16.9498 15.5351L18.364 14.1208L20.9997 16.7565V20.9999H16.7578L14.1214 18.3635ZM5.63597 9.87806L2.80754 7.04963C2.41702 6.65911 2.41702 6.02594 2.80754 5.63542L5.63597 2.80699C6.02649 2.41647 6.65966 2.41647 7.05018 2.80699L9.87861 5.63542L8.4644 7.04963L6.34308 4.92831L4.92886 6.34253L7.05018 8.46385L5.63597 9.87806Z" fill="currentColor"/>`,
"picture-in-picture-2": `<path d="M21 3C21.5523 3 22 3.44772 22 4V11H20V5H4V19H10V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM21 13C21.5523 13 22 13.4477 22 14V20C22 20.5523 21.5523 21 21 21H13C12.4477 21 12 20.5523 12 20V14C12 13.4477 12.4477 13 13 13H21ZM20 15H14V19H20V15ZM6.70711 6.29289L8.95689 8.54289L11 6.5V12H5.5L7.54289 9.95689L5.29289 7.70711L6.70711 6.29289Z" fill="currentColor"/>`,
"pie-chart": `<path d="M9 2.4578V4.58152C6.06817 5.76829 4 8.64262 4 12C4 16.4183 7.58172 20 12 20C15.3574 20 18.2317 17.9318 19.4185 15H21.5422C20.2679 19.0571 16.4776 22 12 22C6.47715 22 2 17.5228 2 12C2 7.52236 4.94289 3.73207 9 2.4578ZM12 2C17.5228 2 22 6.47715 22 12C22 12.3375 21.9833 12.6711 21.9506 13H11V2.04938C11.3289 2.01672 11.6625 2 12 2ZM13 4.06189V11H19.9381C19.4869 7.38128 16.6187 4.51314 13 4.06189Z" fill="currentColor"/>`,
"play": `<path d="M16.3944 12.0001L10 7.7371V16.263L16.3944 12.0001ZM19.376 12.4161L8.77735 19.4818C8.54759 19.635 8.23715 19.5729 8.08397 19.3432C8.02922 19.261 8 19.1645 8 19.0658V4.93433C8 4.65818 8.22386 4.43433 8.5 4.43433C8.59871 4.43433 8.69522 4.46355 8.77735 4.5183L19.376 11.584C19.6057 11.7372 19.6678 12.0477 19.5146 12.2774C19.478 12.3323 19.4309 12.3795 19.376 12.4161Z" fill="currentColor"/>`,
@@ -211,7 +210,6 @@ export const iconSpriteData = {
"shuffle": `<path d="M18 17.8832V16L23 19L18 22V19.9095C14.9224 19.4698 12.2513 17.4584 11.0029 14.5453L11 14.5386L10.9971 14.5453C9.57893 17.8544 6.32508 20 2.72483 20H2V18H2.72483C5.52503 18 8.05579 16.3312 9.15885 13.7574L9.91203 12L9.15885 10.2426C8.05579 7.66878 5.52503 6 2.72483 6H2V4H2.72483C6.32508 4 9.57893 6.14557 10.9971 9.45473L11 9.46141L11.0029 9.45473C12.2513 6.5416 14.9224 4.53022 18 4.09051V2L23 5L18 8V6.11684C15.7266 6.53763 13.7737 8.0667 12.8412 10.2426L12.088 12L12.8412 13.7574C13.7737 15.9333 15.7266 17.4624 18 17.8832Z" fill="currentColor"/>`,
"slash-commands-2": `<path d="M5 2C3.34315 2 2 3.34315 2 5V19C2 20.6569 3.34315 22 5 22H19C20.6569 22 22 20.6569 22 19V5C22 3.34315 20.6569 2 19 2H5ZM4 5C4 4.44772 4.44772 4 5 4H19C19.5523 4 20 4.44772 20 5V19C20 19.5523 19.5523 20 19 20H5C4.44772 20 4 19.5523 4 19V5ZM9.72318 18L16.5803 6H14.2768L7.41968 18H9.72318Z" fill="currentColor"/>`,
"smartphone": `<path d="M7 4V20H17V4H7ZM6 2H18C18.5523 2 19 2.44772 19 3V21C19 21.5523 18.5523 22 18 22H6C5.44772 22 5 21.5523 5 21V3C5 2.44772 5.44772 2 6 2ZM12 17C12.5523 17 13 17.4477 13 18C13 18.5523 12.5523 19 12 19C11.4477 19 11 18.5523 11 18C11 17.4477 11.4477 17 12 17Z" fill="currentColor"/>`,
"sort-desc": `<path d="M20 4V16H23L19 21L15 16H18V4H20ZM12 18V20H3V18H12ZM14 11V13H3V11H14ZM14 4V6H3V4H14Z" fill="currentColor"/>`,
"sparkling": `<path d="M14 4.4375C15.3462 4.4375 16.4375 3.34619 16.4375 2H17.5625C17.5625 3.34619 18.6538 4.4375 20 4.4375V5.5625C18.6538 5.5625 17.5625 6.65381 17.5625 8H16.4375C16.4375 6.65381 15.3462 5.5625 14 5.5625V4.4375ZM1 11C4.31371 11 7 8.31371 7 5H9C9 8.31371 11.6863 11 15 11V13C11.6863 13 9 15.6863 9 19H7C7 15.6863 4.31371 13 1 13V11ZM4.87601 12C6.18717 12.7276 7.27243 13.8128 8 15.124 8.72757 13.8128 9.81283 12.7276 11.124 12 9.81283 11.2724 8.72757 10.1872 8 8.87601 7.27243 10.1872 6.18717 11.2724 4.87601 12ZM17.25 14C17.25 15.7949 15.7949 17.25 14 17.25V18.75C15.7949 18.75 17.25 20.2051 17.25 22H18.75C18.75 20.2051 20.2051 18.75 22 18.75V17.25C20.2051 17.25 18.75 15.7949 18.75 14H17.25Z" fill="currentColor"/>`,
"split-cells-horizontal": `<path d="M20 3C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3H20ZM11 5H5V19H11V15H13V19H19V5H13V9H11V5ZM15 9L18 12L15 15V13H9V15L6 12L9 9V11H15V9Z" fill="currentColor"/>`,
"stack": `<path d="M20.0833 15.1999L21.2854 15.9212C21.5221 16.0633 21.5989 16.3704 21.4569 16.6072C21.4146 16.6776 21.3557 16.7365 21.2854 16.7787L12.5144 22.0412C12.1977 22.2313 11.8021 22.2313 11.4854 22.0412L2.71451 16.7787C2.47772 16.6366 2.40093 16.3295 2.54301 16.0927C2.58523 16.0223 2.64413 15.9634 2.71451 15.9212L3.9166 15.1999L11.9999 20.0499L20.0833 15.1999ZM20.0833 10.4999L21.2854 11.2212C21.5221 11.3633 21.5989 11.6704 21.4569 11.9072C21.4146 11.9776 21.3557 12.0365 21.2854 12.0787L11.9999 17.6499L2.71451 12.0787C2.47772 11.9366 2.40093 11.6295 2.54301 11.3927C2.58523 11.3223 2.64413 11.2634 2.71451 11.2212L3.9166 10.4999L11.9999 15.3499L20.0833 10.4999ZM12.5144 1.30864L21.2854 6.5712C21.5221 6.71327 21.5989 7.0204 21.4569 7.25719C21.4146 7.32757 21.3557 7.38647 21.2854 7.42869L11.9999 12.9999L2.71451 7.42869C2.47772 7.28662 2.40093 6.97949 2.54301 6.7427C2.58523 6.67232 2.64413 6.61343 2.71451 6.5712L11.4854 1.30864C11.8021 1.11864 12.1977 1.11864 12.5144 1.30864ZM11.9999 3.33233L5.88723 6.99995L11.9999 10.6676L18.1126 6.99995L11.9999 3.33233Z" fill="currentColor"/>`,
@@ -206,7 +206,7 @@ export const MainLayout: React.FC = () => {
return;
}
sessionState.openNewSessionDraft();
sessionState.openNewSessionDraft({ automatic: true });
}, delayMs);
};
@@ -6,7 +6,10 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils';
export const ProjectContextPanel: React.FC = () => {
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
onOpenPlan?: (plan: { path: string; title: string }) => void;
}> = ({ onActionComplete, onOpenPlan }) => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
@@ -51,6 +54,8 @@ export const ProjectContextPanel: React.FC = () => {
projectRef={projectRef}
projectLabel={projectLabel}
canCreateWorktree={canCreateWorktree}
onActionComplete={onActionComplete}
onOpenPlan={onOpenPlan}
/>
</div>
);
@@ -446,7 +446,7 @@ export const VSCodeLayout: React.FC = () => {
// No initialSessionId means open a new session draft
if (!initialSessionId) {
hasAppliedInitialSession.current = true;
openNewSessionDraft();
openNewSessionDraft({ automatic: true });
return;
}
@@ -78,6 +78,9 @@ interface ProjectNotesTodoPanelProps {
projectLabel?: string | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
/** When provided, opening a plan calls this instead of the desktop context
panel tab hosts without ContextPanel (mobile) render their own viewer. */
onOpenPlan?: (plan: { path: string; title: string }) => void;
className?: string;
}
@@ -162,6 +165,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectLabel,
canCreateWorktree = false,
onActionComplete,
onOpenPlan,
className,
}) => {
const { t } = useI18n();
@@ -725,6 +729,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanListItem) => {
if (onOpenPlan) {
onOpenPlan({ path: plan.path, title: plan.title });
return;
}
const projectPath = projectRef?.path?.trim();
const panelDirectory = currentDirectory?.trim() || projectPath;
if (!panelDirectory) {
@@ -737,7 +745,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
label: plan.title,
});
},
[currentDirectory, openContextPanelTab, projectRef]
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef]
);
if (!projectRef) {
@@ -8,6 +8,7 @@ import { useGitAllBranches } from '@/stores/useGitStore';
import type { SessionNode } from '../types';
import { isPathWithinProject } from '../utils';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
import { useSessionUIStore } from '@/sync/session-ui-store';
export type SwitcherItem = {
node: SessionNode;
@@ -23,6 +24,8 @@ const MAX_PARENT_SESSIONS = 7;
type SwitcherItemsOptions = {
scopeProjectId?: string | null;
/** How many parent sessions to return (default 7 — the desktop dropdown). */
maxParents?: number;
};
const normalize = (value: string | null | undefined): string | null => {
@@ -41,12 +44,30 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
};
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
const { scopeProjectId = null } = options;
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
const branchesByDirectory = useGitAllBranches();
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
// Worktree sessions live OUTSIDE their project's path, so prefix matching
// can't resolve their project — and their branch is known from worktree
// discovery long before any git status is fetched for that directory.
const worktreeInfoByPath = React.useMemo(() => {
const map = new Map<string, { projectPath: string; branch: string | null }>();
for (const [projectPath, worktrees] of availableWorktreesByProject) {
const normalizedProjectPath = normalize(projectPath);
if (!normalizedProjectPath) continue;
for (const worktree of worktrees) {
const worktreePath = normalize(worktree.path);
if (!worktreePath) continue;
map.set(worktreePath, { projectPath: normalizedProjectPath, branch: worktree.branch?.trim() || null });
}
}
return map;
}, [availableWorktreesByProject]);
const normalizedProjects = React.useMemo(
() => projects
@@ -58,12 +79,18 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const findProjectForDirectory = React.useCallback(
(directory: string | null) => {
if (!directory) return null;
// Known worktree → its project, regardless of where the worktree lives.
const worktreeInfo = worktreeInfoByPath.get(normalize(directory) ?? directory);
if (worktreeInfo) {
const byPath = normalizedProjects.find((project) => project.normalizedPath === worktreeInfo.projectPath);
if (byPath) return byPath;
}
const matches = normalizedProjects
.filter((project) => isPathWithinProject(directory, project.normalizedPath))
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
return matches[0] ?? null;
},
[normalizedProjects],
[normalizedProjects, worktreeInfoByPath],
);
const items = React.useMemo<SwitcherItem[]>(() => {
@@ -94,7 +121,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
return findProjectForDirectory(directory)?.id === scopeProjectId;
})
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
.slice(0, MAX_PARENT_SESSIONS);
.slice(0, maxParents);
const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? [];
@@ -109,7 +136,11 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const directory = resolveGlobalSessionDirectory(session);
const matchedProject = findProjectForDirectory(directory);
const projectLabel = formatProjectLabel(matchedProject);
const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null;
// Live git branch when available; the discovered worktree branch fills
// in for directories whose git status hasn't been fetched yet.
const worktreeInfo = directory ? worktreeInfoByPath.get(normalize(directory) ?? directory) : null;
const liveBranch = directory ? branchesByDirectory.get(directory) : undefined;
const branchLabel = liveBranch ?? worktreeInfo?.branch ?? null;
return {
node: buildNode(session),
projectId: matchedProject?.id ?? null,
@@ -120,7 +151,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId, sessionOrderRanks]);
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
return items;
};
@@ -44,6 +44,11 @@ type SortableTabsStripProps = {
inactiveTabsIconOnly?: boolean;
animateActivePill?: boolean;
activePillLowercase?: boolean;
/** Position the active-pill indicator with left/top instead of translate3d.
Use when the strip lives inside an ancestor that transform-animates
(e.g. a sliding mobile drawer): creating a composited layer mid-slide
flickers in WKWebView. Tab-switch animation stays (layout transition). */
nonCompositedIndicator?: boolean;
className?: string;
};
@@ -100,6 +105,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
inactiveTabsIconOnly = false,
animateActivePill,
activePillLowercase = true,
nonCompositedIndicator = false,
className,
}) => {
const { t } = useI18n();
@@ -409,13 +415,21 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
// than a hard border, so the pill reads as raised above the track.
'border border-[color-mix(in_srgb,var(--foreground)_7%,transparent)]',
'shadow-[0_1px_2px_color-mix(in_srgb,var(--foreground)_10%,transparent),0_2px_6px_color-mix(in_srgb,var(--foreground)_6%,transparent)]',
shouldAnimateActivePill && pillTransitionEnabled && 'pill-tabs__indicator--is-animated'
shouldAnimateActivePill && pillTransitionEnabled
&& (nonCompositedIndicator ? 'pill-tabs__indicator--is-animated-layout' : 'pill-tabs__indicator--is-animated')
)}
style={{
transform: `translate3d(${pillRect.left + pillNudge}px, ${pillRect.top}px, 0)`,
width: `${pillRect.width}px`,
height: `${pillRect.height}px`,
}}
style={nonCompositedIndicator
? {
left: `${pillRect.left + pillNudge}px`,
top: `${pillRect.top}px`,
width: `${pillRect.width}px`,
height: `${pillRect.height}px`,
}
: {
transform: `translate3d(${pillRect.left + pillNudge}px, ${pillRect.top}px, 0)`,
width: `${pillRect.width}px`,
height: `${pillRect.height}px`,
}}
/>
) : null}
{useUnderlineIndicator && pillRect ? (
+10 -5
View File
@@ -725,7 +725,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim());
const showEditorTabsRow = isMobile || mode !== 'editor-only';
// editor-only hosts (desktop context panel, the mobile Files surface) bring
// their own chrome — the open-file tabs row is redundant there.
const showEditorTabsRow = mode !== 'editor-only';
const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile;
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const gitStatus = useGitStatus(currentDirectory);
@@ -3753,10 +3755,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
) : null}
{/* Row 2: Docked editor toolbar (expanded). Desktop-only opt-in. */}
{settingsExpandedEditorToolbar && !isMobile && selectedFile ? (
{/* Row 2: Docked editor toolbar (expanded). Desktop opt-in; ALWAYS on
for mobile floating hover controls don't work with touch. */}
{(settingsExpandedEditorToolbar || isMobile) && selectedFile ? (
<div className="flex min-w-0 items-center gap-3 border-t border-border/40 bg-[var(--surface-subtle)] px-3 py-1">
{displaySelectedPath ? (
{/* Mobile hosts already show the file name in their own header;
a truncated duplicate here just eats toolbar width. */}
{displaySelectedPath && !isMobile ? (
<span
className="min-w-0 flex-1 truncate typography-meta text-muted-foreground"
title={displaySelectedPath}
@@ -3773,7 +3778,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
{selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar && !isMobile) && (
{selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar || isMobile) && (
<div
ref={floatingToolbarRef}
className="absolute right-3 top-3 z-30"
@@ -48,6 +48,9 @@ import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
/** Called after a send action routes the user to the chat hosts that show
PlanView in an overlay (mobile fullscreen surface) close it here. */
onNavigatedToChat?: () => void;
};
type PlanSendAction = 'improve' | 'implement';
@@ -147,7 +150,7 @@ type SelectedLineRange = {
end: number;
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigatedToChat }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
@@ -526,7 +529,8 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
onNavigatedToChat?.();
}, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]);
const handleConfirmPlanSend = React.useCallback(
async (execution: TodoSendExecution) => {
@@ -242,7 +242,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
const autoNavSlugRef = React.useRef<string | null>(null);
// Seed with the mount-time slug when opening at the nav stage: the slug
// persists across opens, and the deep-link auto-jump below must react only
// to slug CHANGES after mount — not re-enter the previously visited page
// every time settings reopen.
const autoNavSlugRef = React.useRef<string | null>(initialMobileStage === 'nav' ? settingsSlug : null);
// No starter page on desktop: 'home' (fresh state) resolves to General.
// settingsPage persists in the UI store, so subsequent opens restore the
@@ -924,7 +928,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{t(`settings.view.nav.group.${group}`)}
</div>
{pages.map((page) => {
const selected = settingsSlug === page.slug;
// On the mobile nav STAGE nothing is "current" — the user is
// choosing, and settingsSlug only remembers the last visited
// page. Keeping it highlighted read as a stuck selection.
const selected = settingsSlug === page.slug && !(isMobile && mobileStage === 'nav');
const iconName = getSettingsNavIcon(page.slug);
if (!iconName && page.slug !== 'mcp') return null;
@@ -1069,15 +1076,19 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{isMobile ? (
<div
className={cn(
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 border-b px-3',
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3',
// The root nav list reads as a single quiet page — no divider and
// no back arrow (the X on the right is the only way out); subpages
// keep both.
mobileStage !== 'nav' && 'border-b',
'bg-background'
)}
style={{ borderColor: 'var(--interactive-border)' }}
style={mobileStage !== 'nav' ? { borderColor: 'var(--interactive-border)' } : undefined}
>
{(showBackButton || onClose) ? (
{showBackButton ? (
<button
type="button"
onClick={showBackButton ? handleBack : onClose}
onClick={handleBack}
aria-label={mobileBackButtonLabel}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
+24
View File
@@ -580,6 +580,19 @@ svg.animate-spin {
background: transparent;
}
/* Fully hidden scrollbar for small mobile overlays (session switcher etc.)
where a visible bar reads as chrome noise. Scrolling stays intact. */
.oc-hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.oc-hide-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
[data-scrollbar="chat"].chat-scroll,
.chat-scroll {
scrollbar-gutter: stable both-edges;
@@ -952,6 +965,16 @@ html:not(.dark) .chat-scroll {
height 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Non-composited variant (left/top-positioned indicator, see
nonCompositedIndicator in sortable-tabs-strip): same travel curve. */
.pill-tabs__indicator--is-animated-layout {
transition:
left 280ms cubic-bezier(0.22, 1, 0.36, 1),
top 260ms cubic-bezier(0.22, 1, 0.36, 1),
width 260ms cubic-bezier(0.22, 1, 0.36, 1),
height 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Underline indicator shares the pill's travel curve so both tab styles feel
like the same control. */
.underline-tabs__indicator--is-animated {
@@ -972,6 +995,7 @@ html:not(.dark) .chat-scroll {
@media (prefers-reduced-motion: reduce) {
.pill-tabs__indicator--is-animated,
.pill-tabs__indicator--is-animated-layout,
.underline-tabs__indicator--is-animated {
transition: none;
}
+7 -27
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
type DeviceType = 'desktop' | 'mobile' | 'tablet';
@@ -65,8 +65,6 @@ const setRootDeviceAttributes = (
}
const root = document.documentElement;
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
root.classList.add(
@@ -79,19 +77,9 @@ const setRootDeviceAttributes = (
if (isDesktopShellRuntime) {
root.classList.add('desktop-runtime');
root.style.setProperty('--is-mobile', '0');
root.style.setProperty('--device-type', 'desktop');
root.style.setProperty('--font-scale', '1');
root.style.setProperty('--has-coarse-pointer', '0');
root.style.setProperty('--has-touch-input', '0');
root.classList.remove('mobile-pointer');
} else {
root.classList.remove('desktop-runtime');
root.style.setProperty('--is-mobile', isMobile ? '1' : '0');
root.style.setProperty('--device-type', deviceType);
root.style.setProperty('--font-scale', isMobile ? '0.9' : isTablet ? '0.95' : '1');
root.style.setProperty('--has-coarse-pointer', hasTouchInput ? '1' : '0');
root.style.setProperty('--has-touch-input', hasTouchInput ? '1' : '0');
if (hasTouchInput) {
root.classList.add('mobile-pointer');
} else {
@@ -128,10 +116,11 @@ export function getDeviceInfo(): DeviceInfo {
isTablet = false;
isDesktop = true;
deviceType = 'desktop';
} else if (isCapacitorApp()) {
// The Capacitor shell IS the phone UI: every surface in that bundle is
// built mobile-first, so wide devices (iPad, Android tablets) must not
// fall into tablet/desktop branches scattered across shared components.
} else if (isMobileSurfaceRuntime()) {
// The mobile surface (Capacitor shell or hosted MobileApp) IS the phone
// UI: every component in that tree is built mobile-first, so wide devices
// (iPad, Android tablets, rotated phones) must not fall into
// tablet/desktop branches scattered across shared components.
// iPad-specific layout upgrades gate on isIPadApp()/orientation instead.
isMobile = true;
isTablet = false;
@@ -288,16 +277,7 @@ const subscribeDeviceInfo = (listener: () => void): (() => void) => {
export function isMobileDeviceViaCSS(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window !== 'undefined' && isDesktopShell()) {
return false;
}
const root = document.documentElement;
const isMobileValue = root.style.getPropertyValue('--is-mobile') ||
getComputedStyle(root).getPropertyValue('--is-mobile');
return isMobileValue === '1' || isMobileValue === 'true';
return readDeviceInfoSnapshot().isMobile;
}
const isStandalonePwaRuntime = (): boolean => {
+11 -19
View File
@@ -52,6 +52,9 @@ export const dict = {
'mobile.connect.unlockButton': 'Unlock and connect',
'mobile.connect.cancelPassword': 'Use another server',
'mobile.connect.connecting': 'Connecting...',
'mobile.connect.notice.unreachable': 'Couldn\'t reach {label}. Check that the server is running.',
'mobile.connect.notice.authExpired': 'Access to {label} has expired or was revoked. Sign in again.',
'mobile.connect.recovery.description': 'Could not connect to the saved server. Check that it is running, or pick another instance.',
'mobile.connect.scanQr': 'Scan QR code',
'mobile.connect.welcome.scanHint': 'On your computer, open «Add a device» to show a QR code, then scan it here.',
'mobile.connect.advanced': 'Advanced',
@@ -88,9 +91,9 @@ export const dict = {
'mobile.nav.settings': 'Settings',
'mobile.surface.closeAria': 'Close',
'mobile.header.openMenuAria': 'Open menu',
'mobile.header.openWorkspaceAria': 'Open workspace panel',
'mobile.header.openMetadataAria': 'Open session metadata',
'mobile.header.metadata.context': 'Context',
'mobile.header.metadata.branch': 'Branch',
'mobile.header.metadata.usage': 'Usage',
'mobile.menu.titleAria': 'Workspace tools',
'mobile.menu.files': 'Files',
@@ -122,18 +125,20 @@ export const dict = {
'mobile.sessions.newChat': 'New chat',
'mobile.sessions.editOrder': 'Reorder projects',
'mobile.sessions.doneEditing': 'Done',
'mobile.sessions.editOrderHint': 'Drag the handle to reorder projects. Tap the check to finish.',
'mobile.sessions.editOrderHint': 'Drag the handle to reorder projects. Tap a project to show its worktrees and drag those too. Tap the check to finish.',
'mobile.sessions.editProjectAria': 'Edit {label}',
'mobile.sessions.dragHandleAria': 'Drag {label} to reorder',
'mobile.sessions.moveUpAria': 'Move {label} up',
'mobile.sessions.moveDownAria': 'Move {label} down',
'mobile.sessions.removeProjectAria': 'Remove {label}',
'mobile.sessions.cancelRemoveProjectAria': 'Cancel removing {label}',
'mobile.sessions.confirmRemoveProject': 'Close',
'mobile.sessions.confirmRemoveProjectAria': 'Confirm removing {label}',
'mobile.sessions.toast.projectRemoved': 'Removed {label}',
'mobile.sessions.archiveSessionAria': 'Archive {title}',
'mobile.sessions.cancelArchiveAria': 'Cancel archiving {title}',
'mobile.sessions.renameSessionAria': 'Rename {title}',
'mobile.sessions.renameError': 'Failed to rename session',
'mobile.sessions.deleteSessionAria': 'Delete {title}',
'mobile.sessions.confirmDeleteSessionAria': 'Confirm deleting {title}',
'mobile.projectEdit.worktreesTitle': 'Worktrees',
'mobile.projectEdit.worktreesEmpty': 'No worktrees in this project yet.',
'mobile.projectEdit.reorderHint': 'Drag to reorder worktrees.',
@@ -179,6 +184,8 @@ export const dict = {
'mobile.files.copyPathAria': 'Copy file path',
'mobile.files.copyContent': 'Copy content',
'mobile.files.copyContentAria': 'Copy file content',
'mobile.files.editAria': 'Edit file',
'mobile.files.doneEditingAria': 'Done editing',
'mobile.files.toast.pathCopied': 'Path copied',
'mobile.files.toast.contentCopied': 'Content copied',
'mobile.files.toast.copyFailed': 'Copy failed',
@@ -2074,21 +2081,6 @@ export const dict = {
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
'chat.chatInput.drop.attachFiles': 'Drop files here to attach',
'chat.chatInput.fileFallback': 'file',
'chat.mobileStatus.editProjects.title': 'Edit Projects',
'chat.mobileStatus.editProjects.footer': 'Drag items to reorder, or use arrows to move. Tap edit to change details.',
'chat.mobileStatus.editProjects.empty': 'No projects to edit',
'chat.mobileStatus.projects.empty': 'No projects',
'chat.mobileStatus.projects.addAria': 'Add project',
'chat.mobileStatus.projects.removeTitle': 'Remove Project',
'chat.mobileStatus.projects.removeDescriptionPrefix': 'Are you sure you want to remove',
'chat.mobileStatus.projects.cancel': 'Cancel',
'chat.mobileStatus.projects.remove': 'Remove',
'chat.mobileStatus.new': 'New',
'chat.mobileStatus.noSessionsInProject': 'No sessions in this project',
'chat.mobileStatus.swipeHint': '← Swipe here to open sidebars →',
'chat.mobileStatus.toast.addProjectFailed': 'Failed to add project',
'chat.mobileStatus.toast.selectValidDirectory': 'Please select a valid directory.',
'chat.mobileStatus.toast.selectDirectoryFailed': 'Failed to select directory',
'chat.toolOutputDialog.image.previousAria': 'Previous image',
'chat.toolOutputDialog.image.nextAria': 'Next image',
'chat.toolOutputDialog.image.closeAria': 'Close image preview',
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.connect.unlockButton": "Desbloquear y conectar",
"mobile.connect.cancelPassword": "Usar otro servidor",
"mobile.connect.connecting": "Conectando...",
"mobile.connect.notice.unreachable": "No se pudo conectar con {label}. Comprueba que el servidor esté en marcha.",
"mobile.connect.notice.authExpired": "El acceso a {label} caducó o fue revocado. Inicia sesión de nuevo.",
"mobile.connect.recovery.description": "No se pudo conectar con el servidor guardado. Comprueba que esté en marcha o elige otra instancia.",
"mobile.connect.scanQr": "Escanear código QR",
"mobile.connect.welcome.scanHint": "En tu ordenador, abre «Añadir un dispositivo» para mostrar un código QR y escanéalo aquí.",
"mobile.connect.advanced": "Avanzado",
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.settings": "Ajustes",
"mobile.surface.closeAria": "Cerrar",
"mobile.header.openMenuAria": "Abrir menú",
"mobile.header.openWorkspaceAria": "Abrir panel de trabajo",
"mobile.header.openMetadataAria": "Abrir metadatos de la sesión",
"mobile.header.metadata.context": "Contexto",
"mobile.header.metadata.branch": "Rama",
"mobile.header.metadata.usage": "Uso",
"mobile.menu.titleAria": "Herramientas del espacio de trabajo",
"mobile.menu.files": "Archivos",
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.newChat": "Nuevo chat",
"mobile.sessions.editOrder": "Reordenar proyectos",
"mobile.sessions.doneEditing": "Listo",
"mobile.sessions.editOrderHint": "Arrastra el asa o usa las flechas para reordenar los proyectos. Toca la marca para finalizar.",
"mobile.sessions.editOrderHint": "Arrastra el asa para reordenar los proyectos. Toca un proyecto para mostrar sus worktrees y arrástralos también. Toca la marca para terminar.",
"mobile.sessions.dragHandleAria": "Arrastra {label} para reordenar",
"mobile.sessions.moveUpAria": "Mover {label} arriba",
"mobile.sessions.moveDownAria": "Mover {label} abajo",
"mobile.sessions.removeProjectAria": "Eliminar {label}",
"mobile.sessions.cancelRemoveProjectAria": "Cancelar eliminación de {label}",
"mobile.sessions.confirmRemoveProject": "Cerrar",
"mobile.sessions.confirmRemoveProjectAria": "Confirmar eliminación de {label}",
"mobile.sessions.toast.projectRemoved": "Se eliminó {label}",
"mobile.sessions.archiveSessionAria": "Archivar {title}",
"mobile.sessions.cancelArchiveAria": "Cancelar archivar {title}",
"mobile.sessions.renameSessionAria": "Renombrar {title}",
"mobile.sessions.renameError": "No se pudo renombrar la sesión",
"mobile.sessions.deleteSessionAria": "Eliminar {title}",
"mobile.sessions.confirmDeleteSessionAria": "Confirmar eliminación de {title}",
"mobile.sessions.editProjectAria": "Editar {label}",
"mobile.projectEdit.worktreesTitle": "Worktrees",
"mobile.projectEdit.worktreesEmpty": "Este proyecto aún no tiene worktrees.",
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
"mobile.files.copyPathAria": "Copiar ruta del archivo",
"mobile.files.copyContent": "Copiar contenido",
"mobile.files.copyContentAria": "Copiar contenido del archivo",
"mobile.files.editAria": "Editar archivo",
"mobile.files.doneEditingAria": "Terminar edición",
"mobile.files.toast.pathCopied": "Ruta copiada",
"mobile.files.toast.contentCopied": "Contenido copiado",
"mobile.files.toast.copyFailed": "No se pudo copiar",
@@ -2040,21 +2047,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.drop.insertMention": "Suelta para insertar como mención",
"chat.chatInput.drop.attachFiles": "Suelta archivos aquí para adjuntar",
"chat.chatInput.fileFallback": "archivo",
"chat.mobileStatus.editProjects.title": "Editar proyectos",
"chat.mobileStatus.editProjects.footer": "Arrastra los elementos para reordenar, o use flechas para mover. Toca editar para cambiar los detalles.",
"chat.mobileStatus.editProjects.empty": "No hay proyectos para editar",
"chat.mobileStatus.projects.empty": "No hay proyectos",
"chat.mobileStatus.projects.addAria": "Añadir proyecto",
"chat.mobileStatus.projects.removeTitle": "Eliminar proyecto",
"chat.mobileStatus.projects.removeDescriptionPrefix": "¿Estás seguro de que quieres eliminar",
"chat.mobileStatus.projects.cancel": "Cancelar",
"chat.mobileStatus.projects.remove": "Eliminar",
"chat.mobileStatus.new": "Nuevo",
"chat.mobileStatus.noSessionsInProject": "No hay sesiones en este proyecto",
"chat.mobileStatus.swipeHint": "← Desliza aquí para abrir los paneles laterales →",
"chat.mobileStatus.toast.addProjectFailed": "No se pudo añadir el proyecto",
"chat.mobileStatus.toast.selectValidDirectory": "Selecciona un directorio válido.",
"chat.mobileStatus.toast.selectDirectoryFailed": "No se pudo seleccionar el directorio",
"chat.toolOutputDialog.image.previousAria": "Imagen anterior",
"chat.toolOutputDialog.image.nextAria": "Imagen siguiente",
"chat.toolOutputDialog.image.closeAria": "Cerrar vista previa de imagen",
+11 -19
View File
@@ -1848,21 +1848,6 @@ export const dict = {
'chat.chatInput.drop.insertMention': 'Déposer pour insérer comme mention',
'chat.chatInput.drop.attachFiles': 'Déposez les fichiers ici pour les joindre',
'chat.chatInput.fileFallback': 'déposer',
'chat.mobileStatus.editProjects.title': 'Modifier des projets',
'chat.mobileStatus.editProjects.footer': 'Faites glisser les éléments pour les réorganiser ou utilisez les flèches pour les déplacer. Appuyez sur modifier pour modifier les détails.',
'chat.mobileStatus.editProjects.empty': 'Aucun projet à modifier',
'chat.mobileStatus.projects.empty': 'Aucun projet',
'chat.mobileStatus.projects.addAria': 'Ajouter un projet',
'chat.mobileStatus.projects.removeTitle': 'Supprimer le projet',
'chat.mobileStatus.projects.removeDescriptionPrefix': 'Etes-vous sûr de vouloir supprimer',
'chat.mobileStatus.projects.cancel': 'Annuler',
'chat.mobileStatus.projects.remove': 'Retirer',
'chat.mobileStatus.new': 'Nouveau',
'chat.mobileStatus.noSessionsInProject': 'Aucune session dans ce projet',
'chat.mobileStatus.swipeHint': '← Glissez ici pour ouvrir les barres latérales →',
'chat.mobileStatus.toast.addProjectFailed': 'Échec de l\'ajout du projet',
'chat.mobileStatus.toast.selectValidDirectory': 'Veuillez sélectionner un répertoire valide.',
'chat.mobileStatus.toast.selectDirectoryFailed': 'Échec de la sélection du répertoire',
'chat.toolOutputDialog.image.previousAria': 'Image précédente',
'chat.toolOutputDialog.image.nextAria': 'Image suivante',
'chat.toolOutputDialog.image.closeAria': 'Fermer l\'aperçu de l\'image',
@@ -2626,6 +2611,9 @@ export const dict = {
'mobile.connect.unlockButton': 'Déverrouiller et se connecter',
'mobile.connect.cancelPassword': 'Utiliser un autre serveur',
'mobile.connect.connecting': 'Connexion...',
'mobile.connect.notice.unreachable': 'Impossible de joindre {label}. Vérifiez que le serveur est en marche.',
'mobile.connect.notice.authExpired': 'L\'accès à {label} a expiré ou a été révoqué. Reconnectez-vous.',
'mobile.connect.recovery.description': 'Connexion au serveur enregistré impossible. Vérifiez qu\'il est en marche ou choisissez une autre instance.',
'mobile.connect.scanQr': 'Scanner le code QR',
'mobile.connect.welcome.scanHint': 'Sur votre ordinateur, ouvrez « Ajouter un appareil » pour afficher un code QR, puis scannez-le ici.',
'mobile.connect.advanced': 'Avancé',
@@ -2662,9 +2650,9 @@ export const dict = {
'mobile.nav.settings': 'Paramètres',
'mobile.surface.closeAria': 'Fermer',
'mobile.header.openMenuAria': 'Ouvrir le menu',
'mobile.header.openWorkspaceAria': 'Ouvrir le panneau de travail',
'mobile.header.openMetadataAria': 'Ouvrir les métadonnées de session',
'mobile.header.metadata.context': 'Contexte',
'mobile.header.metadata.branch': 'Branche',
'mobile.header.metadata.usage': 'Utilisation',
'mobile.menu.titleAria': 'Outils de lespace de travail',
'mobile.menu.files': 'Fichiers',
@@ -2696,18 +2684,20 @@ export const dict = {
'mobile.sessions.newChat': 'Nouveau chat',
'mobile.sessions.editOrder': 'Réordonner les projets',
'mobile.sessions.doneEditing': 'Terminé',
'mobile.sessions.editOrderHint': 'Faites glisser la poignée pour réordonner les projets. Touchez la coche pour terminer.',
'mobile.sessions.editOrderHint': 'Faites glisser la poignée pour réorganiser les projets. Touchez un projet pour afficher ses worktrees et les faire glisser aussi. Touchez la coche pour terminer.',
'mobile.sessions.editProjectAria': 'Modifier {label}',
'mobile.sessions.dragHandleAria': 'Faire glisser {label} pour réordonner',
'mobile.sessions.moveUpAria': 'Déplacer {label} vers le haut',
'mobile.sessions.moveDownAria': 'Déplacer {label} vers le bas',
'mobile.sessions.removeProjectAria': 'Supprimer {label}',
'mobile.sessions.cancelRemoveProjectAria': 'Annuler la suppression de {label}',
'mobile.sessions.confirmRemoveProject': 'Fermer',
'mobile.sessions.confirmRemoveProjectAria': 'Confirmer la suppression de {label}',
'mobile.sessions.toast.projectRemoved': '{label} supprimé',
'mobile.sessions.archiveSessionAria': 'Archiver {title}',
'mobile.sessions.cancelArchiveAria': 'Annuler larchivage de {title}',
'mobile.sessions.renameSessionAria': 'Renommer {title}',
'mobile.sessions.renameError': 'Échec du renommage de la session',
'mobile.sessions.deleteSessionAria': 'Supprimer {title}',
'mobile.sessions.confirmDeleteSessionAria': 'Confirmer la suppression de {title}',
'mobile.projectEdit.worktreesTitle': 'Worktrees',
'mobile.projectEdit.worktreesEmpty': 'Aucun worktree dans ce projet pour le moment.',
'mobile.projectEdit.reorderHint': 'Faites glisser pour réordonner les worktrees.',
@@ -2753,6 +2743,8 @@ export const dict = {
'mobile.files.copyPathAria': 'Copier le chemin du fichier',
'mobile.files.copyContent': 'Copier le contenu',
'mobile.files.copyContentAria': 'Copier le contenu du fichier',
'mobile.files.editAria': 'Modifier le fichier',
'mobile.files.doneEditingAria': 'Terminer la modification',
'mobile.files.toast.pathCopied': 'Chemin copié',
'mobile.files.toast.contentCopied': 'Contenu copié',
'mobile.files.toast.copyFailed': 'Échec de la copie',
+11 -19
View File
@@ -56,6 +56,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。',
'mobile.connect.connectButton': '接続',
'mobile.connect.connecting': '接続中...',
'mobile.connect.notice.unreachable': '{label}に接続できませんでした。サーバーが起動しているか確認してください。',
'mobile.connect.notice.authExpired': '{label}へのアクセスは期限切れか取り消されました。再度サインインしてください。',
'mobile.connect.recovery.description': '保存済みサーバーに接続できませんでした。サーバーが起動しているか確認するか、別のインスタンスを選んでください。',
'mobile.connect.password.label': 'パスワード',
'mobile.connect.password.placeholder': 'OpenChamber のパスワード',
'mobile.connect.unlockButton': 'ロックを解除して接続',
@@ -90,9 +93,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.cancelDeleteAria': '{label} を残す',
'mobile.surface.closeAria': '閉じる',
'mobile.header.openMenuAria': 'メニューを開く',
'mobile.header.openWorkspaceAria': 'ワークスペースパネルを開く',
'mobile.header.openMetadataAria': 'セッションメタデータを開く',
'mobile.header.metadata.context': 'コンテキスト',
'mobile.header.metadata.branch': 'ブランチ',
'mobile.header.metadata.usage': '使用量',
'mobile.menu.titleAria': 'ワークスペースツール',
'mobile.menu.files': 'ファイル',
@@ -123,18 +126,20 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.newChat': '新しいチャット',
'mobile.sessions.editOrder': 'プロジェクトの並び替え',
'mobile.sessions.doneEditing': '完了',
'mobile.sessions.editOrderHint': 'ハンドルをドラッグしてプロジェクトを並替えます。チェックをタップして完了します。',
'mobile.sessions.editOrderHint': 'ハンドルをドラッグしてプロジェクトを並替えます。プロジェクトをタップするとワークツリーが表示され、同様にドラッグできます。チェックをタップして完了します。',
'mobile.sessions.editProjectAria': '{label}を編集',
'mobile.sessions.dragHandleAria': '{label}をドラッグして並び替え',
'mobile.sessions.moveUpAria': '{label}を上に移動',
'mobile.sessions.moveDownAria': '{label}を下に移動',
'mobile.sessions.removeProjectAria': '{label}を削除',
'mobile.sessions.cancelRemoveProjectAria': '{label}の削除をキャンセル',
'mobile.sessions.confirmRemoveProject': '閉じる',
'mobile.sessions.confirmRemoveProjectAria': '{label}の削除を確認',
'mobile.sessions.toast.projectRemoved': '{label}を削除しました',
'mobile.sessions.archiveSessionAria': '{title}をアーカイブ',
'mobile.sessions.cancelArchiveAria': '{title}のアーカイブをキャンセル',
'mobile.sessions.renameSessionAria': '{title}の名前を変更',
'mobile.sessions.renameError': 'セッション名の変更に失敗しました',
'mobile.sessions.deleteSessionAria': '{title}を削除',
'mobile.sessions.confirmDeleteSessionAria': '{title}の削除を確認',
'mobile.projectEdit.worktreesTitle': 'ワークツリー',
'mobile.projectEdit.worktreesEmpty': 'このプロジェクトにはまだワークツリーがありません。',
'mobile.projectEdit.reorderHint': 'ドラッグしてワークツリーを並び替え。',
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
'mobile.files.copyPathAria': 'ファイルパスをコピー',
'mobile.files.copyContent': '内容をコピー',
'mobile.files.copyContentAria': 'ファイル内容をコピー',
'mobile.files.editAria': 'ファイルを編集',
'mobile.files.doneEditingAria': '編集を終了',
'mobile.files.toast.pathCopied': 'パスをコピーしました',
'mobile.files.toast.contentCopied': '内容をコピーしました',
'mobile.files.toast.copyFailed': 'コピーに失敗しました',
@@ -2073,21 +2080,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入',
'chat.chatInput.drop.attachFiles': 'ここにファイルをドロップして添付',
'chat.chatInput.fileFallback': 'ファイル',
'chat.mobileStatus.editProjects.title': 'プロジェクトを編集',
'chat.mobileStatus.editProjects.footer': '項目をドラッグして並び替え、または矢印で移動。編集をタップして詳細を変更します。',
'chat.mobileStatus.editProjects.empty': '編集するプロジェクトがありません',
'chat.mobileStatus.projects.empty': 'プロジェクトがありません',
'chat.mobileStatus.projects.addAria': 'プロジェクトを追加',
'chat.mobileStatus.projects.removeTitle': 'プロジェクトを削除',
'chat.mobileStatus.projects.removeDescriptionPrefix': '本当に削除しますか?',
'chat.mobileStatus.projects.cancel': 'キャンセル',
'chat.mobileStatus.projects.remove': '削除',
'chat.mobileStatus.new': '新規',
'chat.mobileStatus.noSessionsInProject': 'このプロジェクトにセッションはありません',
'chat.mobileStatus.swipeHint': '← ここをスワイプでサイドバーを開く →',
'chat.mobileStatus.toast.addProjectFailed': 'プロジェクトの追加に失敗しました',
'chat.mobileStatus.toast.selectValidDirectory': '有効なディレクトリを選択してください。',
'chat.mobileStatus.toast.selectDirectoryFailed': 'ディレクトリの選択に失敗しました',
'chat.toolOutputDialog.image.previousAria': '前の画像',
'chat.toolOutputDialog.image.nextAria': '次の画像',
'chat.toolOutputDialog.image.closeAria': '画像プレビューを閉じる',
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.unlockButton': '잠금 해제 후 연결',
'mobile.connect.cancelPassword': '다른 서버 사용',
'mobile.connect.connecting': '연결 중...',
'mobile.connect.notice.unreachable': '{label}에 연결할 수 없습니다. 서버가 실행 중인지 확인하세요.',
'mobile.connect.notice.authExpired': '{label} 액세스가 만료되었거나 취소되었습니다. 다시 로그인하세요.',
'mobile.connect.recovery.description': '저장된 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하거나 다른 인스턴스를 선택하세요.',
'mobile.connect.scanQr': 'QR 코드 스캔',
'mobile.connect.welcome.scanHint': '컴퓨터에서 「기기 추가」를 열어 QR 코드를 표시한 뒤 여기에서 스캔하세요.',
'mobile.connect.advanced': '고급',
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.settings': '설정',
'mobile.surface.closeAria': '닫기',
'mobile.header.openMenuAria': '메뉴 열기',
'mobile.header.openWorkspaceAria': '작업 공간 패널 열기',
'mobile.header.openMetadataAria': '세션 메타데이터 열기',
'mobile.header.metadata.context': '컨텍스트',
'mobile.header.metadata.branch': '브랜치',
'mobile.header.metadata.usage': '사용량',
'mobile.menu.titleAria': '작업 공간 도구',
'mobile.menu.files': '파일',
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.newChat': '새 채팅',
'mobile.sessions.editOrder': '프로젝트 순서 변경',
'mobile.sessions.doneEditing': '완료',
'mobile.sessions.editOrderHint': '핸들을 드래그하거나 화살표로 프로젝트 순서를 변경하세요. 완료하려면 체크를 누르세요.',
'mobile.sessions.editOrderHint': '핸들을 드래그하 프로젝트 순서를 변경하세요. 프로젝트를 탭하면 워크트리가 표시되며 같은 방식으로 드래그할 수 있습니다. 체크를 탭하면 완료됩니다.',
'mobile.sessions.dragHandleAria': '{label} 드래그하여 순서 변경',
'mobile.sessions.moveUpAria': '{label} 위로 이동',
'mobile.sessions.moveDownAria': '{label} 아래로 이동',
'mobile.sessions.removeProjectAria': '{label} 제거',
'mobile.sessions.cancelRemoveProjectAria': '{label} 제거 취소',
'mobile.sessions.confirmRemoveProject': '닫기',
'mobile.sessions.confirmRemoveProjectAria': '{label} 제거 확인',
'mobile.sessions.toast.projectRemoved': '{label} 제거됨',
'mobile.sessions.archiveSessionAria': '{title} 보관',
'mobile.sessions.cancelArchiveAria': '{title} 보관 취소',
'mobile.sessions.renameSessionAria': '{title} 이름 바꾸기',
'mobile.sessions.renameError': '세션 이름 변경에 실패했습니다',
'mobile.sessions.deleteSessionAria': '{title} 삭제',
'mobile.sessions.confirmDeleteSessionAria': '{title} 삭제 확인',
'mobile.sessions.editProjectAria': '{label} 편집',
'mobile.projectEdit.worktreesTitle': '워크트리',
'mobile.projectEdit.worktreesEmpty': '이 프로젝트에는 아직 워크트리가 없습니다.',
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
'mobile.files.copyPathAria': '파일 경로 복사',
'mobile.files.copyContent': '내용 복사',
'mobile.files.copyContentAria': '파일 내용 복사',
'mobile.files.editAria': '파일 편집',
'mobile.files.doneEditingAria': '편집 완료',
'mobile.files.toast.pathCopied': '경로가 복사되었습니다',
'mobile.files.toast.contentCopied': '내용이 복사되었습니다',
'mobile.files.toast.copyFailed': '복사하지 못했습니다',
@@ -2074,21 +2081,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.drop.insertMention': '여기에 놓아 멘션으로 추가',
'chat.chatInput.drop.attachFiles': '여기에 놓아 파일 첨부',
'chat.chatInput.fileFallback': '파일',
'chat.mobileStatus.editProjects.title': '편집 프로젝트',
'chat.mobileStatus.editProjects.footer': '항목을 드래그하여 순서를 바꾸거나 화살표로 이동하세요. 세부 정보는 편집을 탭해 변경하세요.',
'chat.mobileStatus.editProjects.empty': '편집할 프로젝트 없음',
'chat.mobileStatus.projects.empty': '프로젝트 없음',
'chat.mobileStatus.projects.addAria': '프로젝트 추가',
'chat.mobileStatus.projects.removeTitle': '프로젝트 제거',
'chat.mobileStatus.projects.removeDescriptionPrefix': '다음을 제거하시겠습니까:',
'chat.mobileStatus.projects.cancel': '취소',
'chat.mobileStatus.projects.remove': '제거',
'chat.mobileStatus.new': '새로 만들기',
'chat.mobileStatus.noSessionsInProject': '이 프로젝트에 세션 없음',
'chat.mobileStatus.swipeHint': '← 여기서 스와이프해 사이드바 열기 →',
'chat.mobileStatus.toast.addProjectFailed': '프로젝트 추가 실패',
'chat.mobileStatus.toast.selectValidDirectory': '유효한 디렉터리를 선택하세요.',
'chat.mobileStatus.toast.selectDirectoryFailed': '디렉터리 선택 실패',
'chat.toolOutputDialog.image.previousAria': '이전 이미지',
'chat.toolOutputDialog.image.nextAria': '다음 이미지',
'chat.toolOutputDialog.image.closeAria': '이미지 미리보기 닫기',
+11 -19
View File
@@ -54,6 +54,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.unlockButton': 'Odblokuj i połącz',
'mobile.connect.cancelPassword': 'Użyj innego serwera',
'mobile.connect.connecting': 'Łączenie...',
'mobile.connect.notice.unreachable': 'Nie udało się połączyć z {label}. Sprawdź, czy serwer działa.',
'mobile.connect.notice.authExpired': 'Dostęp do {label} wygasł lub został cofnięty. Zaloguj się ponownie.',
'mobile.connect.recovery.description': 'Nie udało się połączyć z zapisanym serwerem. Sprawdź, czy działa, albo wybierz inną instancję.',
'mobile.connect.scanQr': 'Skanuj kod QR',
'mobile.connect.welcome.scanHint': 'Na komputerze otwórz «Dodaj urządzenie», aby wyświetlić kod QR, i zeskanuj go tutaj.',
'mobile.connect.advanced': 'Zaawansowane',
@@ -90,9 +93,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.settings': 'Ustawienia',
'mobile.surface.closeAria': 'Zamknij',
'mobile.header.openMenuAria': 'Otwórz menu',
'mobile.header.openWorkspaceAria': 'Otwórz panel roboczy',
'mobile.header.openMetadataAria': 'Otwórz metadane sesji',
'mobile.header.metadata.context': 'Kontekst',
'mobile.header.metadata.branch': 'Gałąź',
'mobile.header.metadata.usage': 'Użycie',
'mobile.menu.titleAria': 'Narzędzia obszaru roboczego',
'mobile.menu.files': 'Pliki',
@@ -124,17 +127,19 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.newChat': 'Nowy czat',
'mobile.sessions.editOrder': 'Zmień kolejność projektów',
'mobile.sessions.doneEditing': 'Gotowe',
'mobile.sessions.editOrderHint': 'Przeciągnij uchwyt lub użyj strzałek, aby zmienić kolejność. Naciśnij znacznik, aby zakończyć.',
'mobile.sessions.editOrderHint': 'Przeciągnij uchwyt, aby zmienić kolejność projektów. Stuknij projekt, aby pokazać jego worktree i również je przeciągać. Stuknij znacznik, aby zakończyć.',
'mobile.sessions.dragHandleAria': 'Przeciągnij {label}, aby zmienić kolejność',
'mobile.sessions.moveUpAria': 'Przenieś {label} w górę',
'mobile.sessions.moveDownAria': 'Przenieś {label} w dół',
'mobile.sessions.removeProjectAria': 'Usuń {label}',
'mobile.sessions.cancelRemoveProjectAria': 'Anuluj usuwanie {label}',
'mobile.sessions.confirmRemoveProject': 'Zamknij',
'mobile.sessions.confirmRemoveProjectAria': 'Potwierdź usunięcie {label}',
'mobile.sessions.toast.projectRemoved': 'Usunięto {label}',
'mobile.sessions.archiveSessionAria': 'Zarchiwizuj {title}',
'mobile.sessions.cancelArchiveAria': 'Anuluj archiwizację {title}',
'mobile.sessions.renameSessionAria': 'Zmień nazwę {title}',
'mobile.sessions.renameError': 'Nie udało się zmienić nazwy sesji',
'mobile.sessions.deleteSessionAria': 'Usuń {title}',
'mobile.sessions.confirmDeleteSessionAria': 'Potwierdź usunięcie {title}',
'mobile.sessions.editProjectAria': 'Edytuj {label}',
'mobile.projectEdit.worktreesTitle': 'Worktree',
'mobile.projectEdit.worktreesEmpty': 'Ten projekt nie ma jeszcze worktree.',
@@ -181,6 +186,8 @@ export const dict: Record<I18nKey, string> = {
'mobile.files.copyPathAria': 'Kopiuj ścieżkę pliku',
'mobile.files.copyContent': 'Kopiuj zawartość',
'mobile.files.copyContentAria': 'Kopiuj zawartość pliku',
'mobile.files.editAria': 'Edytuj plik',
'mobile.files.doneEditingAria': 'Zakończ edycję',
'mobile.files.toast.pathCopied': 'Ścieżka skopiowana',
'mobile.files.toast.contentCopied': 'Zawartość skopiowana',
'mobile.files.toast.copyFailed': 'Kopiowanie nie powiodło się',
@@ -1238,21 +1245,6 @@ export const dict: Record<I18nKey, string> = {
'chat.reasoningTrace.expandAria': 'Rozwiń ślad rozumowania',
'chat.reasoningTrace.collapseAria': 'Zwiń ślad rozumowania',
'chat.reasoningTrace.thought': 'Przemyślał',
'chat.mobileStatus.editProjects.empty': 'Brak projektów do edycji',
'chat.mobileStatus.editProjects.footer': 'Przeciągnij elementy, aby zmienić kolejność, lub użyj strzałek do przesuwania. Dotknij „edytuj”, aby zmienić szczegóły.',
'chat.mobileStatus.editProjects.title': 'Edytuj projekty',
'chat.mobileStatus.new': 'Nowy',
'chat.mobileStatus.noSessionsInProject': 'Brak sesji w tym projekcie',
'chat.mobileStatus.projects.addAria': 'Dodaj projekt',
'chat.mobileStatus.projects.cancel': 'Anuluj',
'chat.mobileStatus.projects.empty': 'Brak projektów',
'chat.mobileStatus.projects.remove': 'Usuń',
'chat.mobileStatus.projects.removeDescriptionPrefix': 'Czy na pewno chcesz usunąć',
'chat.mobileStatus.projects.removeTitle': 'Usuń projekt',
'chat.mobileStatus.swipeHint': '← Przesuń tutaj, aby otworzyć paski boczne →',
'chat.mobileStatus.toast.addProjectFailed': 'Nie udało się dodać projektu',
'chat.mobileStatus.toast.selectDirectoryFailed': 'Nie udało się wybrać katalogu',
'chat.mobileStatus.toast.selectValidDirectory': 'Wybierz prawidłowy katalog.',
'chat.modelControls.addNewProvider': 'Dodaj nowego dostawcę',
'chat.modelControls.addToFavorites': 'Dodaj do ulubionych',
'chat.modelControls.bash': 'Bash',
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.connect.unlockButton": "Desbloquear e conectar",
"mobile.connect.cancelPassword": "Usar outro servidor",
"mobile.connect.connecting": "Conectando...",
"mobile.connect.notice.unreachable": "Não foi possível conectar a {label}. Verifique se o servidor está em execução.",
"mobile.connect.notice.authExpired": "O acesso a {label} expirou ou foi revogado. Entre novamente.",
"mobile.connect.recovery.description": "Não foi possível conectar ao servidor salvo. Verifique se ele está em execução ou escolha outra instância.",
"mobile.connect.scanQr": "Ler código QR",
"mobile.connect.welcome.scanHint": "No seu computador, abra «Adicionar um dispositivo» para mostrar um código QR e escaneie aqui.",
"mobile.connect.advanced": "Avançado",
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.settings": "Configurações",
"mobile.surface.closeAria": "Fechar",
"mobile.header.openMenuAria": "Abrir menu",
"mobile.header.openWorkspaceAria": "Abrir painel de trabalho",
"mobile.header.openMetadataAria": "Abrir metadados da sessão",
"mobile.header.metadata.context": "Contexto",
"mobile.header.metadata.branch": "Branch",
"mobile.header.metadata.usage": "Uso",
"mobile.menu.titleAria": "Ferramentas do espaço de trabalho",
"mobile.menu.files": "Arquivos",
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.newChat": "Novo chat",
"mobile.sessions.editOrder": "Reordenar projetos",
"mobile.sessions.doneEditing": "Concluído",
"mobile.sessions.editOrderHint": "Arraste a alça ou use as setas para reordenar os projetos. Toque na marca para finalizar.",
"mobile.sessions.editOrderHint": "Arraste a alça para reordenar os projetos. Toque em um projeto para mostrar seus worktrees e arraste-os também. Toque na marca para concluir.",
"mobile.sessions.dragHandleAria": "Arrastar {label} para reordenar",
"mobile.sessions.moveUpAria": "Mover {label} para cima",
"mobile.sessions.moveDownAria": "Mover {label} para baixo",
"mobile.sessions.removeProjectAria": "Remover {label}",
"mobile.sessions.cancelRemoveProjectAria": "Cancelar remoção de {label}",
"mobile.sessions.confirmRemoveProject": "Fechar",
"mobile.sessions.confirmRemoveProjectAria": "Confirmar remoção de {label}",
"mobile.sessions.toast.projectRemoved": "Removido {label}",
"mobile.sessions.archiveSessionAria": "Arquivar {title}",
"mobile.sessions.cancelArchiveAria": "Cancelar arquivamento de {title}",
"mobile.sessions.renameSessionAria": "Renomear {title}",
"mobile.sessions.renameError": "Falha ao renomear a sessão",
"mobile.sessions.deleteSessionAria": "Excluir {title}",
"mobile.sessions.confirmDeleteSessionAria": "Confirmar exclusão de {title}",
"mobile.sessions.editProjectAria": "Editar {label}",
"mobile.projectEdit.worktreesTitle": "Worktrees",
"mobile.projectEdit.worktreesEmpty": "Este projeto ainda não tem worktrees.",
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
"mobile.files.copyPathAria": "Copiar caminho do arquivo",
"mobile.files.copyContent": "Copiar conteúdo",
"mobile.files.copyContentAria": "Copiar conteúdo do arquivo",
"mobile.files.editAria": "Editar arquivo",
"mobile.files.doneEditingAria": "Concluir edição",
"mobile.files.toast.pathCopied": "Caminho copiado",
"mobile.files.toast.contentCopied": "Conteúdo copiado",
"mobile.files.toast.copyFailed": "Falha ao copiar",
@@ -2040,21 +2047,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.drop.insertMention": "Solte para inserir como menção",
"chat.chatInput.drop.attachFiles": "Solte arquivos aqui para anexar",
"chat.chatInput.fileFallback": "arquivo",
"chat.mobileStatus.editProjects.title": "Editar projetos",
"chat.mobileStatus.editProjects.footer": "Arraste os itens para reordenar ou use as setas para mover. Toque em editar para alterar os detalhes.",
"chat.mobileStatus.editProjects.empty": "Não há projetos para editar",
"chat.mobileStatus.projects.empty": "Não há projetos",
"chat.mobileStatus.projects.addAria": "Adicionar projeto",
"chat.mobileStatus.projects.removeTitle": "Excluir projeto",
"chat.mobileStatus.projects.removeDescriptionPrefix": "Tem certeza de que deseja excluir",
"chat.mobileStatus.projects.cancel": "Cancelar",
"chat.mobileStatus.projects.remove": "Excluir",
"chat.mobileStatus.new": "Novo",
"chat.mobileStatus.noSessionsInProject": "Não há sessões neste projeto",
"chat.mobileStatus.swipeHint": "← Deslize aqui para abrir os painéis laterais →",
"chat.mobileStatus.toast.addProjectFailed": "Não foi possível adicionar o projeto",
"chat.mobileStatus.toast.selectValidDirectory": "Selecione um diretório válido.",
"chat.mobileStatus.toast.selectDirectoryFailed": "Não foi possível selecionar o diretório",
"chat.toolOutputDialog.image.previousAria": "Imagem anterior",
"chat.toolOutputDialog.image.nextAria": "Próxima imagem",
"chat.toolOutputDialog.image.closeAria": "Fechar prévia de imagem",
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.connect.unlockButton": "Розблокувати і підключити",
"mobile.connect.cancelPassword": "Інший сервер",
"mobile.connect.connecting": "Підключення...",
"mobile.connect.notice.unreachable": "Не вдалося з'єднатися з {label}. Перевірте, що сервер запущено.",
"mobile.connect.notice.authExpired": "Доступ до {label} протух або був відкликаний. Увійдіть знову.",
"mobile.connect.recovery.description": "Не вдалося з'єднатися зі збереженим сервером. Перевірте, що він запущений, або оберіть інший інстанс.",
"mobile.connect.scanQr": "Сканувати QR-код",
"mobile.connect.welcome.scanHint": "На компʼютері відкрийте «Додати пристрій», щоб показати QR-код, і відскануйте його тут.",
"mobile.connect.advanced": "Додатково",
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.settings": "Налаштування",
"mobile.surface.closeAria": "Закрити",
"mobile.header.openMenuAria": "Відкрити меню",
"mobile.header.openWorkspaceAria": "Відкрити робочу панель",
"mobile.header.openMetadataAria": "Відкрити метадані сесії",
"mobile.header.metadata.context": "Контекст",
"mobile.header.metadata.branch": "Гілка",
"mobile.header.metadata.usage": "Використання",
"mobile.menu.titleAria": "Інструменти робочого простору",
"mobile.menu.files": "Файли",
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
"mobile.sessions.newChat": "Новий чат",
"mobile.sessions.editOrder": "Змінити порядок проєктів",
"mobile.sessions.doneEditing": "Готово",
"mobile.sessions.editOrderHint": "Перетягни ручку або скористайся стрілками, щоб змінити порядок проєктів. Натисни галочку щоб завершити.",
"mobile.sessions.editOrderHint": "Перетягни ручку, щоб змінити порядок проєктів. Торкнись проєкту, щоб показати його worktree, і перетягуй їх так само. Натисни галочку, щоб завершити.",
"mobile.sessions.dragHandleAria": "Перетягни {label}, щоб змінити порядок",
"mobile.sessions.moveUpAria": "Перемістити {label} вгору",
"mobile.sessions.moveDownAria": "Перемістити {label} вниз",
"mobile.sessions.removeProjectAria": "Видалити {label}",
"mobile.sessions.cancelRemoveProjectAria": "Скасувати видалення {label}",
"mobile.sessions.confirmRemoveProject": "Закрити",
"mobile.sessions.confirmRemoveProjectAria": "Підтвердити видалення {label}",
"mobile.sessions.toast.projectRemoved": "Видалено {label}",
"mobile.sessions.archiveSessionAria": "Архівувати {title}",
"mobile.sessions.cancelArchiveAria": "Скасувати архівування {title}",
"mobile.sessions.renameSessionAria": "Перейменувати {title}",
"mobile.sessions.renameError": "Не вдалося перейменувати сесію",
"mobile.sessions.deleteSessionAria": "Видалити {title}",
"mobile.sessions.confirmDeleteSessionAria": "Підтвердити видалення {title}",
"mobile.sessions.editProjectAria": "Редагувати {label}",
"mobile.projectEdit.worktreesTitle": "Ворктрі",
"mobile.projectEdit.worktreesEmpty": "У цьому проєкті ще немає ворктрі.",
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
"mobile.files.copyPathAria": "Скопіювати шлях до файлу",
"mobile.files.copyContent": "Скопіювати вміст",
"mobile.files.copyContentAria": "Скопіювати вміст файлу",
"mobile.files.editAria": "Редагувати файл",
"mobile.files.doneEditingAria": "Завершити редагування",
"mobile.files.toast.pathCopied": "Шлях скопійовано",
"mobile.files.toast.contentCopied": "Вміст скопійовано",
"mobile.files.toast.copyFailed": "Не вдалося скопіювати",
@@ -2040,21 +2047,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.drop.insertMention": "Відпустіть, щоб вставити як згадку",
"chat.chatInput.drop.attachFiles": "Перетягніть файли сюди, щоб прикріпити",
"chat.chatInput.fileFallback": "файл",
"chat.mobileStatus.editProjects.title": "Редагувати проєкти",
"chat.mobileStatus.editProjects.footer": "Перетягніть елементи, щоб змінити порядок, або використовуйте стрілки, щоб перемістити. Натисніть «Редагувати», щоб змінити деталі.",
"chat.mobileStatus.editProjects.empty": "Немає проєктів для редагування",
"chat.mobileStatus.projects.empty": "Жодних проєктів",
"chat.mobileStatus.projects.addAria": "Додати проєкт",
"chat.mobileStatus.projects.removeTitle": "Видалити проєкт",
"chat.mobileStatus.projects.removeDescriptionPrefix": "Ви впевнені, що хочете видалити",
"chat.mobileStatus.projects.cancel": "Скасувати",
"chat.mobileStatus.projects.remove": "Видалити",
"chat.mobileStatus.new": "Новий",
"chat.mobileStatus.noSessionsInProject": "У цьому проєкті немає сесій",
"chat.mobileStatus.swipeHint": "← Проведіть пальцем тут, щоб відкрити бічні панелі →",
"chat.mobileStatus.toast.addProjectFailed": "Не вдалося додати проєкт",
"chat.mobileStatus.toast.selectValidDirectory": "Виберіть правильний каталог.",
"chat.mobileStatus.toast.selectDirectoryFailed": "Не вдалося вибрати каталог",
"chat.toolOutputDialog.image.previousAria": "Попереднє зображення",
"chat.toolOutputDialog.image.nextAria": "Наступне зображення",
"chat.toolOutputDialog.image.closeAria": "Закрити попередній перегляд зображення",
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.unlockButton': '解锁并连接',
'mobile.connect.cancelPassword': '使用其他服务器',
'mobile.connect.connecting': '连接中...',
'mobile.connect.notice.unreachable': '无法连接到 {label}。请确认服务器正在运行。',
'mobile.connect.notice.authExpired': '对 {label} 的访问已过期或被撤销。请重新登录。',
'mobile.connect.recovery.description': '无法连接到已保存的服务器。请确认它正在运行,或选择其他实例。',
'mobile.connect.scanQr': '扫描二维码',
'mobile.connect.welcome.scanHint': '在电脑上打开「添加设备」显示二维码,然后在这里扫描。',
'mobile.connect.advanced': '高级',
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.settings': '设置',
'mobile.surface.closeAria': '关闭',
'mobile.header.openMenuAria': '打开菜单',
'mobile.header.openWorkspaceAria': '打开工作区面板',
'mobile.header.openMetadataAria': '打开会话元数据',
'mobile.header.metadata.context': '上下文',
'mobile.header.metadata.branch': '分支',
'mobile.header.metadata.usage': '用量',
'mobile.menu.titleAria': '工作区工具',
'mobile.menu.files': '文件',
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.newChat': '新会话',
'mobile.sessions.editOrder': '重新排序项目',
'mobile.sessions.doneEditing': '完成',
'mobile.sessions.editOrderHint': '拖动手柄或使用箭头重新排序。点击对勾完成。',
'mobile.sessions.editOrderHint': '拖动手柄以重新排序项目。点按项目可显示其工作树并同样拖动排序。点按对勾完成。',
'mobile.sessions.dragHandleAria': '拖动 {label} 以重新排序',
'mobile.sessions.moveUpAria': '将 {label} 上移',
'mobile.sessions.moveDownAria': '将 {label} 下移',
'mobile.sessions.removeProjectAria': '移除 {label}',
'mobile.sessions.cancelRemoveProjectAria': '取消移除 {label}',
'mobile.sessions.confirmRemoveProject': '关闭',
'mobile.sessions.confirmRemoveProjectAria': '确认移除 {label}',
'mobile.sessions.toast.projectRemoved': '已移除 {label}',
'mobile.sessions.archiveSessionAria': '归档 {title}',
'mobile.sessions.cancelArchiveAria': '取消归档 {title}',
'mobile.sessions.renameSessionAria': '重命名 {title}',
'mobile.sessions.renameError': '重命名会话失败',
'mobile.sessions.deleteSessionAria': '删除 {title}',
'mobile.sessions.confirmDeleteSessionAria': '确认删除 {title}',
'mobile.sessions.editProjectAria': '编辑 {label}',
'mobile.projectEdit.worktreesTitle': '工作树',
'mobile.projectEdit.worktreesEmpty': '此项目还没有工作树。',
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
'mobile.files.copyPathAria': '复制文件路径',
'mobile.files.copyContent': '复制内容',
'mobile.files.copyContentAria': '复制文件内容',
'mobile.files.editAria': '编辑文件',
'mobile.files.doneEditingAria': '完成编辑',
'mobile.files.toast.pathCopied': '路径已复制',
'mobile.files.toast.contentCopied': '内容已复制',
'mobile.files.toast.copyFailed': '复制失败',
@@ -2040,21 +2047,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.drop.insertMention': '释放以插入为提及',
'chat.chatInput.drop.attachFiles': '将文件拖放到此处以附加',
'chat.chatInput.fileFallback': '文件',
'chat.mobileStatus.editProjects.title': '编辑项目',
'chat.mobileStatus.editProjects.footer': '拖动可重新排序,或使用箭头移动。点击编辑可修改详情。',
'chat.mobileStatus.editProjects.empty': '没有可编辑的项目',
'chat.mobileStatus.projects.empty': '没有项目',
'chat.mobileStatus.projects.addAria': '添加项目',
'chat.mobileStatus.projects.removeTitle': '移除项目',
'chat.mobileStatus.projects.removeDescriptionPrefix': '确定要移除',
'chat.mobileStatus.projects.cancel': '取消',
'chat.mobileStatus.projects.remove': '移除',
'chat.mobileStatus.new': '新建',
'chat.mobileStatus.noSessionsInProject': '该项目中没有会话',
'chat.mobileStatus.swipeHint': '← 在此滑动以打开侧边栏 →',
'chat.mobileStatus.toast.addProjectFailed': '添加项目失败',
'chat.mobileStatus.toast.selectValidDirectory': '请选择有效目录。',
'chat.mobileStatus.toast.selectDirectoryFailed': '选择目录失败',
'chat.toolOutputDialog.image.previousAria': '上一张图片',
'chat.toolOutputDialog.image.nextAria': '下一张图片',
'chat.toolOutputDialog.image.closeAria': '关闭图片预览',
+11 -19
View File
@@ -53,6 +53,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.connect.unlockButton': '解鎖並連線',
'mobile.connect.cancelPassword': '使用其他伺服器',
'mobile.connect.connecting': '連線中...',
'mobile.connect.notice.unreachable': '無法連線至 {label}。請確認伺服器正在執行。',
'mobile.connect.notice.authExpired': '對 {label} 的存取已過期或被撤銷。請重新登入。',
'mobile.connect.recovery.description': '無法連線至已儲存的伺服器。請確認它正在執行,或選擇其他執行個體。',
'mobile.connect.scanQr': '掃描 QR code',
'mobile.connect.welcome.scanHint': '在電腦上開啟「新增裝置」顯示 QR 代碼,然後在這裡掃描。',
'mobile.connect.advanced': '進階',
@@ -89,9 +92,9 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.settings': '設定',
'mobile.surface.closeAria': '關閉',
'mobile.header.openMenuAria': '開啟選單',
'mobile.header.openWorkspaceAria': '開啟工作區面板',
'mobile.header.openMetadataAria': '開啟工作階段中繼資料',
'mobile.header.metadata.context': '上下文',
'mobile.header.metadata.branch': '分支',
'mobile.header.metadata.usage': '用量',
'mobile.menu.titleAria': '工作區工具',
'mobile.menu.files': '檔案',
@@ -123,17 +126,19 @@ export const dict: Record<I18nKey, string> = {
'mobile.sessions.newChat': '新聊天',
'mobile.sessions.editOrder': '重新排序專案',
'mobile.sessions.doneEditing': '完成',
'mobile.sessions.editOrderHint': '拖曳把手或使用箭頭重新排序專案。點勾號完成。',
'mobile.sessions.editOrderHint': '拖曳手柄以重新排序專案。點按專案可顯示其工作樹並同樣拖曳排序。點按勾號完成。',
'mobile.sessions.dragHandleAria': '拖曳 {label} 以重新排序',
'mobile.sessions.moveUpAria': '將 {label} 上移',
'mobile.sessions.moveDownAria': '將 {label} 下移',
'mobile.sessions.removeProjectAria': '移除 {label}',
'mobile.sessions.cancelRemoveProjectAria': '取消移除 {label}',
'mobile.sessions.confirmRemoveProject': '關閉',
'mobile.sessions.confirmRemoveProjectAria': '確認移除 {label}',
'mobile.sessions.toast.projectRemoved': '已移除 {label}',
'mobile.sessions.archiveSessionAria': '封存 {title}',
'mobile.sessions.cancelArchiveAria': '取消封存 {title}',
'mobile.sessions.renameSessionAria': '重新命名 {title}',
'mobile.sessions.renameError': '重新命名工作階段失敗',
'mobile.sessions.deleteSessionAria': '刪除 {title}',
'mobile.sessions.confirmDeleteSessionAria': '確認刪除 {title}',
'mobile.sessions.editProjectAria': '編輯 {label}',
'mobile.projectEdit.worktreesTitle': '工作樹',
'mobile.projectEdit.worktreesEmpty': '此專案還沒有工作樹。',
@@ -180,6 +185,8 @@ export const dict: Record<I18nKey, string> = {
'mobile.files.copyPathAria': '複製檔案路徑',
'mobile.files.copyContent': '複製內容',
'mobile.files.copyContentAria': '複製檔案內容',
'mobile.files.editAria': '編輯檔案',
'mobile.files.doneEditingAria': '完成編輯',
'mobile.files.toast.pathCopied': '路徑已複製',
'mobile.files.toast.contentCopied': '內容已複製',
'mobile.files.toast.copyFailed': '複製失敗',
@@ -2044,21 +2051,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.drop.insertMention': '放開以插入為提及',
'chat.chatInput.drop.attachFiles': '將檔案拖放至此處以附加',
'chat.chatInput.fileFallback': '檔案',
'chat.mobileStatus.editProjects.title': '編輯專案',
'chat.mobileStatus.editProjects.footer': '拖曳可重新排序,或使用箭頭移動。點擊編輯可修改詳情。',
'chat.mobileStatus.editProjects.empty': '沒有可編輯的專案',
'chat.mobileStatus.projects.empty': '沒有專案',
'chat.mobileStatus.projects.addAria': '新增專案',
'chat.mobileStatus.projects.removeTitle': '移除專案',
'chat.mobileStatus.projects.removeDescriptionPrefix': '確定要移除',
'chat.mobileStatus.projects.cancel': '取消',
'chat.mobileStatus.projects.remove': '移除',
'chat.mobileStatus.new': '新增',
'chat.mobileStatus.noSessionsInProject': '該專案中沒有會話',
'chat.mobileStatus.swipeHint': '← 在此滑動以開啟側邊欄 →',
'chat.mobileStatus.toast.addProjectFailed': '新增專案失敗',
'chat.mobileStatus.toast.selectValidDirectory': '請選擇有效目錄。',
'chat.mobileStatus.toast.selectDirectoryFailed': '選擇目錄失敗',
'chat.toolOutputDialog.image.previousAria': '上一張圖片',
'chat.toolOutputDialog.image.nextAria': '下一張圖片',
'chat.toolOutputDialog.image.closeAria': '關閉圖片預覽',
+34 -7
View File
@@ -1,4 +1,6 @@
import { isDesktopShell } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
import { getStoredMobileLayoutPreference } from '@/lib/mobileLayoutPreference';
export type HostedSurface = 'desktop' | 'mobile';
@@ -20,7 +22,14 @@ const isTouchOrCoarsePointer = (): boolean => {
return coarsePointer || touchPoints > 0;
};
const detectHostedSurface = (): HostedSurface => {
/**
* Single authority for the mobile-vs-desktop surface decision.
*
* Priority: explicit stamp (set once at boot) URL override Capacitor
* shell (always the mobile surface) desktop shells phone heuristic
* gated by the stored mobile layout preference.
*/
export const detectHostedSurface = (): HostedSurface => {
if (typeof window === 'undefined') return 'desktop';
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
@@ -33,12 +42,30 @@ const detectHostedSurface = (): HostedSurface => {
return override;
}
if (isDesktopShell()) return 'desktop';
if (isCapacitorApp()) return 'mobile';
if (isDesktopShell() || isVSCodeRuntime()) return 'desktop';
const width = window.innerWidth || window.screen?.width || 0;
return width > 0 && width <= MOBILE_SURFACE_MAX_WIDTH && isTouchOrCoarsePointer()
? 'mobile'
: 'desktop';
const width = Math.min(
window.innerWidth || Number.POSITIVE_INFINITY,
window.screen?.width || Number.POSITIVE_INFINITY,
);
const likelyPhone = Number.isFinite(width)
&& width <= MOBILE_SURFACE_MAX_WIDTH
&& isTouchOrCoarsePointer();
return likelyPhone && getStoredMobileLayoutPreference() === 'new' ? 'mobile' : 'desktop';
};
/**
* Decides the surface once and stamps it on `window` so every later
* `isMobileSurfaceRuntime()` call (perf tuning, sync paging, device info)
* reads the same stable answer instead of re-running viewport heuristics.
*/
export const resolveHostedSurface = (): HostedSurface => {
const surface = detectHostedSurface();
if (typeof window !== 'undefined') {
window.__OPENCHAMBER_SURFACE__ = surface;
}
return surface;
};
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
@@ -5,6 +5,7 @@ import { listGlobalSessionPages } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
import { raiseSessionOrderingBaselines } from '@/sync/session-ordering';
import { mapWithConcurrency } from '@/lib/concurrency';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -491,6 +492,10 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
status: 'idle',
applySnapshot: (activeSessions, archivedSessions, status = 'ready') => {
// An authoritative snapshot may carry newer `updated` stamps for sessions
// whose active→settled cycle this client slept through — raise their
// ordering baselines so recent lists re-sort (see session-ordering).
raiseSessionOrderingBaselines(activeSessions);
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
},
-20
View File
@@ -706,9 +706,6 @@ interface UIStore {
expandedEditorToolbar: boolean;
showSplitAssistantMessageActions: boolean;
allowPromptingSubagentSessions: boolean;
isMobileSessionStatusBarCollapsed: boolean;
mobileSessionPanelOpen: boolean;
mobileSessionFilterProjectId: string | null;
isExpandedInput: boolean;
reportUsage: boolean;
shortcutOverrides: Record<string, ShortcutCombo>;
@@ -869,9 +866,6 @@ interface UIStore {
setExpandedEditorToolbar: (value: boolean) => void;
setShowSplitAssistantMessageActions: (value: boolean) => void;
setAllowPromptingSubagentSessions: (value: boolean) => void;
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
setMobileSessionPanelOpen: (value: boolean) => void;
setMobileSessionFilterProjectId: (value: string | null) => void;
viewPagerPage: 'left' | 'center' | 'right';
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
toggleExpandedInput: () => void;
@@ -1022,9 +1016,6 @@ export const useUIStore = create<UIStore>()(
showSplitAssistantMessageActions: false,
allowPromptingSubagentSessions: false,
draftStartersVisible: true,
isMobileSessionStatusBarCollapsed: false,
mobileSessionPanelOpen: false,
mobileSessionFilterProjectId: null,
isExpandedInput: false,
reportUsage: true,
shortcutOverrides: {},
@@ -2209,15 +2200,6 @@ export const useUIStore = create<UIStore>()(
setAllowPromptingSubagentSessions: (value) => {
set({ allowPromptingSubagentSessions: value });
},
setIsMobileSessionStatusBarCollapsed: (value) => {
set({ isMobileSessionStatusBarCollapsed: value });
},
setMobileSessionPanelOpen: (value) => {
set({ mobileSessionPanelOpen: value });
},
setMobileSessionFilterProjectId: (value) => {
set({ mobileSessionFilterProjectId: value });
},
setReportUsage: (value) => {
set({ reportUsage: value });
},
@@ -2489,8 +2471,6 @@ export const useUIStore = create<UIStore>()(
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
allowPromptingSubagentSessions: state.allowPromptingSubagentSessions,
draftStartersVisible: state.draftStartersVisible,
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
mobileSessionFilterProjectId: state.mobileSessionFilterProjectId,
shortcutOverrides: state.shortcutOverrides,
fileEditorKeymap: state.fileEditorKeymap,
})
+11 -117
View File
@@ -1,43 +1,5 @@
/* Mobile Adaptations - All Devices */
/* Set global device type variables */
:root {
--is-mobile: 0;
--device-type: 'desktop';
--font-scale: 1;
}
@media (max-width: 1024px) {
:root.mobile-pointer:not(.desktop-runtime) {
--is-mobile: 1;
--device-type: 'mobile';
--font-scale: 0.9;
}
}
/* Utility classes for device-specific styling */
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
@media (max-width: 1024px) {
:root.mobile-pointer:not(.desktop-runtime) .desktop-only {
display: none !important;
}
:root.desktop-runtime .mobile-only {
display: none !important;
}
:root.mobile-pointer:not(.desktop-runtime) .mobile-only {
display: block !important;
}
}
/* General mobile improvements for all mobile devices */
@media (max-width: 1024px) {
/* Override CSS custom properties for mobile */
@@ -82,7 +44,10 @@
/* Improve touch targets for mobile */
:root.mobile-pointer:not(.desktop-runtime) button:not([role="radio"]):not([role="checkbox"]):not([role="switch"]),
:root.mobile-pointer:not(.desktop-runtime) .btn,
:root.mobile-pointer:not(.desktop-runtime) [role="button"] {
:root.mobile-pointer:not(.desktop-runtime) [role="button"],
/* Static chat tool rows: not interactive as a whole, but they must share
the 36px rhythm of the [role="button"] expandable/reasoning rows. */
:root.mobile-pointer:not(.desktop-runtime) .oc-static-tool-row {
min-height: 36px;
min-width: 36px;
}
@@ -144,17 +109,6 @@
-webkit-touch-callout: default;
}
/* Improve mobile spacing */
:root.mobile-pointer:not(.desktop-runtime) .px-4 {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
:root.mobile-pointer:not(.desktop-runtime) .py-2 {
padding-top: 0.75rem !important;
padding-bottom: 0.75rem !important;
}
/* Fix mobile scroll containers */
:root.mobile-pointer:not(.desktop-runtime) .overflow-hidden {
overflow-x: hidden !important;
@@ -205,30 +159,6 @@
min-height: 0;
}
/* Mobile sidebar positioning */
:root.mobile-pointer:not(.desktop-runtime) .mobile-sidebar-top {
top: var(--header-height, 3rem);
height: calc(100vh - var(--header-height, 3rem));
}
/* Set header height variable */
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
--header-height: 3rem;
}
/* For mobile devices with larger header */
@media (max-width: 768px) {
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
--header-height: 3.5rem;
}
}
/* For tablet devices */
@media (min-width: 769px) and (max-width: 1024px) {
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
--header-height: 3.25rem;
}
}
}
/* iOS PWA Adaptations */
@@ -241,7 +171,7 @@
--oc-safe-area-top: env(safe-area-inset-top, 0);
--oc-safe-area-right: env(safe-area-inset-right, 0);
--oc-safe-area-bottom: env(safe-area-inset-bottom, 0);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.05), 4px);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
--oc-safe-area-left: env(safe-area-inset-left, 0);
}
@@ -258,25 +188,10 @@
/* Phase 1: iOS PWA safe area handling - Enhanced positioning approach */
@media (display-mode: standalone) {
/* iOS-specific safe area handling with CSS variable support */
/* iOS-specific safe area handling. The --oc-safe-area-* tokens and the
.header-safe-area padding are already set by the unconditional iOS block
above; only the standalone-specific rules live here. */
@supports (-webkit-touch-callout: none) {
/* Safe area handling for fixed positioned elements */
:root.device-mobile:not(.desktop-runtime),
:root.device-tablet:not(.desktop-runtime),
:root.mobile-pointer:not(.desktop-runtime) {
--oc-safe-area-top: env(safe-area-inset-top, 0);
--oc-safe-area-right: env(safe-area-inset-right, 0);
--oc-safe-area-bottom: env(safe-area-inset-bottom, 0);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.05), 4px);
--oc-safe-area-left: env(safe-area-inset-left, 0);
}
:root.device-mobile:not(.desktop-runtime) .header-safe-area,
:root.device-tablet:not(.desktop-runtime) .header-safe-area,
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
padding-top: var(--oc-safe-area-top);
}
:root.device-mobile:not(.desktop-runtime) .main-content-safe-area,
:root.device-tablet:not(.desktop-runtime) .main-content-safe-area,
:root.mobile-pointer:not(.desktop-runtime) .main-content-safe-area {
@@ -293,14 +208,6 @@
padding-bottom: var(--oc-safe-area-bottom-visual) !important;
}
/* Drawer safe area - top already offset by header height */
:root.device-mobile:not(.desktop-runtime) .drawer-safe-area,
:root.device-tablet:not(.desktop-runtime) .drawer-safe-area,
:root.mobile-pointer:not(.desktop-runtime) .drawer-safe-area {
padding-top: 0;
padding-bottom: var(--oc-safe-area-bottom-visual);
}
/* Fix iOS viewport issues */
:root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen,
:root.device-tablet:not(.desktop-runtime) .flex.flex-col.h-screen,
@@ -318,19 +225,6 @@
-webkit-overflow-scrolling: touch;
}
/* Update header height for iOS */
:root.device-mobile:not(.desktop-runtime) .header-safe-area,
:root.device-tablet:not(.desktop-runtime) .header-safe-area,
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
--header-height: 4.5rem;
}
:root.device-mobile:not(.desktop-runtime) .mobile-sidebar-top,
:root.device-tablet:not(.desktop-runtime) .mobile-sidebar-top,
:root.mobile-pointer:not(.desktop-runtime) .mobile-sidebar-top {
--header-height: 4.5rem;
}
/* Mobile typography improvements */
.typography-markdown {
font-size: 1rem !important;
@@ -370,7 +264,7 @@
--oc-safe-area-top: env(safe-area-inset-top, 0);
--oc-safe-area-right: env(safe-area-inset-right, 0);
--oc-safe-area-bottom: env(safe-area-inset-bottom, 0);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.05), 4px);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
--oc-safe-area-left: env(safe-area-inset-left, 0);
}
@@ -537,7 +431,7 @@
--oc-safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
--oc-safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
--oc-safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.05), 4px);
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
--oc-safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
@@ -784,7 +678,7 @@
collapsing browser chrome, so the dynamic and large viewports are the same
thing pin the shell to 100lvh, which always reports full screen height.
Initial geometry is identical (lvh == dvh on load). */
:root:not(.oc-capacitor-app) .h-\[100dvh\] {
:root:not(.oc-capacitor-app) .oc-mobile-app-shell {
height: 100lvh;
}
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
class TestStorage implements Storage {
readonly values = new Map<string, string>()
get length() { return this.values.size }
clear() { this.values.clear() }
getItem(key: string) { return this.values.get(key) ?? null }
key(index: number) { return [...this.values.keys()][index] ?? null }
removeItem(key: string) { this.values.delete(key) }
setItem(key: string, value: string) { this.values.set(key, value) }
}
let storage: TestStorage
beforeEach(() => {
storage = new TestStorage()
})
describe("last active session persistence", () => {
test("keeps independent entries per runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-a", directory: "/repo/a" }, storage)
persistLastActiveSession("runtime-b", { sessionId: "ses-b", directory: null }, storage)
expect(readLastActiveSession("runtime-a", storage)).toEqual({ sessionId: "ses-a", directory: "/repo/a" })
expect(readLastActiveSession("runtime-b", storage)).toEqual({ sessionId: "ses-b", directory: null })
})
test("overwrites the entry for the same runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-1", directory: "/repo" }, storage)
persistLastActiveSession("runtime-a", { sessionId: "ses-2", directory: null }, storage)
expect(readLastActiveSession("runtime-a", storage)).toEqual({ sessionId: "ses-2", directory: null })
})
test("clear removes only the targeted runtime", () => {
persistLastActiveSession("runtime-a", { sessionId: "ses-a", directory: null }, storage)
persistLastActiveSession("runtime-b", { sessionId: "ses-b", directory: null }, storage)
clearLastActiveSession("runtime-a", storage)
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
expect(readLastActiveSession("runtime-b", storage)).toEqual({ sessionId: "ses-b", directory: null })
})
test("malformed persisted payload reads as empty, not a crash", () => {
storage.setItem("oc.lastSession.v1", "{not json")
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
storage.setItem("oc.lastSession.v1", JSON.stringify({ version: 99, runtimes: { "runtime-a": { sessionId: "x" } } }))
expect(readLastActiveSession("runtime-a", storage)).toBeNull()
})
test("bounds retained runtime namespaces", () => {
for (let index = 0; index < 10; index += 1) {
persistLastActiveSession(`runtime-${index}`, { sessionId: `ses-${index}`, directory: null }, storage)
}
const retained = Array.from({ length: 10 }, (_, index) => readLastActiveSession(`runtime-${index}`, storage))
.filter(Boolean)
expect(retained.length).toBe(8)
// Newest entries survive.
expect(readLastActiveSession("runtime-9", storage)).not.toBeNull()
expect(readLastActiveSession("runtime-0", storage)).toBeNull()
})
})
@@ -0,0 +1,91 @@
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
// Persisted "last active session" per runtime (server instance), so a cold
// app launch can reopen the session the user had open the last time this
// instance was connected. This is startup-continuity context ONLY — callers
// must confirm the session still exists against an authoritative snapshot
// before opening it (see the MobileApp restore effect).
const STORAGE_KEY = "oc.lastSession.v1"
const MAX_RUNTIME_ENTRIES = 8
export type PersistedLastSession = {
sessionId: string
directory: string | null
}
type PersistedEntry = PersistedLastSession & { updatedAt: number }
type PersistedEnvelope = {
version: 1
runtimes: Record<string, PersistedEntry>
}
const emptyEnvelope = (): PersistedEnvelope => ({ version: 1, runtimes: {} })
const readEnvelope = (storage: Storage): PersistedEnvelope => {
try {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) return emptyEnvelope()
const parsed = JSON.parse(raw) as Partial<PersistedEnvelope>
if (parsed.version !== 1 || !parsed.runtimes || typeof parsed.runtimes !== "object") return emptyEnvelope()
const runtimes: Record<string, PersistedEntry> = {}
for (const [runtimeKey, entry] of Object.entries(parsed.runtimes)) {
if (!runtimeKey || !entry || typeof entry.sessionId !== "string" || entry.sessionId.length === 0) continue
runtimes[runtimeKey] = {
sessionId: entry.sessionId,
directory: typeof entry.directory === "string" && entry.directory.length > 0 ? entry.directory : null,
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : 0,
}
}
return { version: 1, runtimes }
} catch {
// Malformed persisted data is a read failure, not empty success — but for
// a pure convenience cache the correct recovery is the same: start fresh.
return emptyEnvelope()
}
}
const writeEnvelope = (storage: Storage, envelope: PersistedEnvelope): void => {
const retained = Object.entries(envelope.runtimes)
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
.slice(0, MAX_RUNTIME_ENTRIES)
try {
storage.setItem(STORAGE_KEY, JSON.stringify({ ...envelope, runtimes: Object.fromEntries(retained) }))
} catch {
// Best-effort cache — a full/blocked storage must never break session switching.
}
}
export function persistLastActiveSession(
runtimeKey: string,
entry: PersistedLastSession,
storage: Storage = getDeferredSafeStorage(),
): void {
if (!runtimeKey || !entry.sessionId) return
const envelope = readEnvelope(storage)
// Monotonic vs the stored entries: same-millisecond writes must not tie,
// or retention trimming would evict an arbitrary runtime.
const maxExisting = Object.values(envelope.runtimes).reduce((max, existing) => Math.max(max, existing.updatedAt), 0)
envelope.runtimes[runtimeKey] = { ...entry, updatedAt: Math.max(Date.now(), maxExisting + 1) }
writeEnvelope(storage, envelope)
}
export function readLastActiveSession(
runtimeKey: string,
storage: Storage = getDeferredSafeStorage(),
): PersistedLastSession | null {
if (!runtimeKey) return null
const entry = readEnvelope(storage).runtimes[runtimeKey]
return entry ? { sessionId: entry.sessionId, directory: entry.directory } : null
}
export function clearLastActiveSession(
runtimeKey: string,
storage: Storage = getDeferredSafeStorage(),
): void {
if (!runtimeKey) return
const envelope = readEnvelope(storage)
if (!envelope.runtimes[runtimeKey]) return
delete envelope.runtimes[runtimeKey]
writeEnvelope(storage, envelope)
}
@@ -8,6 +8,7 @@ import {
removeSessionOrdering,
resetSessionOrdering,
useSessionOrderingStore,
raiseSessionOrderingBaselines,
} from './session-ordering';
const session = (
@@ -135,4 +136,25 @@ describe('session lifecycle ordering', () => {
'active-child',
]);
});
test('authoritative snapshot raises frozen baselines without live ranks', () => {
const older = session('older', 10);
const newer = session('newer', 20);
// Freeze both baselines at their first-seen timestamps.
expect(compareSessionsByLifecycleOrder(older, newer, new Set(), new Map())).toBeGreaterThan(0);
// A metadata-only live update must NOT reorder (frozen baseline)...
const liveBump = session('older', 30);
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeGreaterThan(0);
// ...but an authoritative snapshot with the newer stamp raises the baseline.
raiseSessionOrderingBaselines([liveBump, newer]);
expect(compareSessionsByLifecycleOrder(liveBump, newer, new Set(), new Map())).toBeLessThan(0);
});
test('store-held stale live rank is raised by an authoritative snapshot', () => {
useSessionOrderingStore.setState({ rankById: new Map([['stale', 15]]) });
raiseSessionOrderingBaselines([session('stale', 40)]);
expect(useSessionOrderingStore.getState().rankById.get('stale')).toBe(40);
});
});
+42
View File
@@ -120,6 +120,48 @@ const baselineRank = (session: Session, pinned: boolean): number => {
return rank;
};
/**
* Raise cached baselines to the sessions' current authoritative timestamps.
*
* The frozen baseline keeps live metadata churn from reordering an open list,
* but a client that slept through a session's whole activesettled cycle never
* saw the transition that would have promoted its live rank so its stale
* baseline pins it in place forever. Call this when an authoritative session
* SNAPSHOT arrives (global refresh); monotonic, so it can never demote.
*/
export const raiseSessionOrderingBaselines = (sessions: Iterable<Session>): void => {
const currentRanks = useSessionOrderingStore.getState().rankById;
let nextRanks: Map<string, number> | null = null;
let baselinesChanged = false;
for (const session of sessions) {
const fresh = updatedAt(session);
const liveRank = currentRanks.get(session.id);
if (liveRank !== undefined) {
// A live rank frozen BEFORE this newer authoritative stamp is stale —
// the session was active again while this client wasn't watching (its
// transition events never arrived, e.g. другий пристрій + сон). Ranks
// share the epoch-ms scale with `updated`, so raising is well-ordered.
if (fresh > liveRank) {
nextRanks = nextRanks ?? new Map(currentRanks);
nextRanks.set(session.id, fresh);
}
continue;
}
const existing = baselineRankById.get(session.id);
if (existing?.updated !== undefined && existing.updated >= fresh) continue;
baselineRankById.set(session.id, { ...existing, updated: fresh });
baselinesChanged = true;
}
if (nextRanks) {
useSessionOrderingStore.setState({ rankById: nextRanks });
} else if (baselinesChanged) {
// Baselines live outside the store; nudge subscribers so open lists re-sort.
useSessionOrderingStore.setState((state) => ({ rankById: new Map(state.rankById) }));
}
};
export const getSessionLifecycleOrderValue = (
session: Session,
rankById: ReadonlyMap<string, number>,
+17 -1
View File
@@ -68,6 +68,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
@@ -284,7 +285,7 @@ export type SessionUIState = {
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
@@ -612,6 +613,12 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if (id) {
persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null })
}
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
@@ -710,6 +717,15 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// openNewSessionDraft
// ---------------------------------------------------------------------------
openNewSessionDraft: (options) => {
// A USER-initiated draft open is a navigation choice: the next cold launch
// should land on the draft, not re-open the session left behind — drop the
// persisted last-session pointer for this runtime. `automatic: true` marks
// programmatic fallback opens (e.g. ChatContainer's "no session active"
// auto-draft at boot), which must NOT consume the pointer — the cold-launch
// restore races exactly that auto-open.
if (!options?.automatic) {
clearLastActiveSession(runtimeMemoryKey())
}
const projectsState = useProjectsStore.getState()
const projects = projectsState.projects
const availableWorktreesByProject = get().availableWorktreesByProject