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
@@ -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"
>