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:
committed by
GitHub
parent
ea8cc5d7b0
commit
86ef96302d
@@ -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'
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user