Merge remote-tracking branch 'upstream/main' into fix/2566-apply-patch-vscode-diff

# Conflicts:
#	packages/ui/src/components/chat/message/parts/ToolPart.tsx
This commit is contained in:
Nabeel Siddiqui
2026-08-01 16:15:42 -04:00
80 changed files with 5544 additions and 4571 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. */}
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'bun:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { EditorAPI } from '@/lib/api/types';
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
const makePatch = (path: string, line: number, before: string, after: string) => [
`--- a/${path}`,
`+++ b/${path}`,
`@@ -${line} +${line} @@`,
`-${before}`,
`+${after}`,
].join('\n');
const files = [
{
filePath: '/workspace/project/src/first.ts',
relativePath: 'src/first.ts',
patch: makePatch('src/first.ts', 4, 'first old', 'first new'),
additions: 1,
deletions: 1,
type: 'update',
},
{
filePath: '/workspace/project/src/second.ts',
relativePath: 'src/second.ts',
patch: makePatch('src/second.ts', 12, 'second old', 'second new'),
additions: 1,
deletions: 1,
type: 'update',
},
];
describe('ApplyPatchFileButtons', () => {
test('renders one labeled button per non-deleted file', () => {
const markup = renderToStaticMarkup(
<ApplyPatchFileButtons
metadata={{ files }}
openDiffLabel="Open file diff"
onFileClick={() => undefined}
/>,
);
expect(markup.match(/<button/g)).toHaveLength(2);
expect(markup).toContain('aria-label="Open file diff: src/first.ts"');
expect(markup).toContain('aria-label="Open file diff: src/second.ts"');
});
test('opens each clicked file with its own authoritative path, patch, and line', () => {
const openDiffCalls: Parameters<EditorAPI['openDiff']>[] = [];
const editor: EditorAPI = {
openDiff: async (...args) => { openDiffCalls.push(args); },
openFile: async () => undefined,
};
let propagationStops = 0;
const stopPropagation = () => { propagationStops += 1; };
const tree = ApplyPatchFileButtons({
metadata: { files },
openDiffLabel: 'Open file diff',
onFileClick: (file, event) => {
event.stopPropagation();
const targetPath = typeof file.relativePath === 'string' ? file.relativePath : '';
openApplyPatchFileInEditor({
currentDirectory: '/workspace/project',
diffLabel: `${targetPath} (changes)`,
editor,
file,
isVSCode: true,
});
},
}) as React.ReactElement<{ children: React.ReactNode }>;
const buttons = React.Children.toArray(tree.props.children) as React.ReactElement<{
onClick: (event: { stopPropagation: () => void }) => void;
}>[];
buttons[0]?.props.onClick({ stopPropagation });
buttons[1]?.props.onClick({ stopPropagation });
expect(propagationStops).toBe(2);
expect(openDiffCalls).toEqual([
['', '/workspace/project/src/first.ts', 'src/first.ts (changes)', {
line: 4,
patch: files[0]?.patch,
}],
['', '/workspace/project/src/second.ts', 'src/second.ts (changes)', {
line: 12,
patch: files[1]?.patch,
}],
]);
});
});
@@ -0,0 +1,134 @@
import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { getApplyPatchFilePath } from './toolDiffUtils';
type ApplyPatchFileEntry = {
file: Record<string, unknown>;
path: string;
name: string;
added: number | null;
removed: number | null;
};
const parseCount = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.trunc(value));
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
}
return null;
};
const combineCounts = (base: number | null, incoming: number | null): number | null => {
if (base === null) return incoming;
if (incoming === null) return base;
return base + incoming;
};
const getApplyPatchFileEntries = (metadata: Record<string, unknown> | undefined): ApplyPatchFileEntry[] => {
const files = Array.isArray(metadata?.files) ? metadata.files : [];
const entriesByPath = new Map<string, ApplyPatchFileEntry>();
for (const file of files) {
if (!file || typeof file !== 'object') continue;
const fileRecord = file as Record<string, unknown>;
const displayPath = typeof fileRecord.relativePath === 'string'
? fileRecord.relativePath
: typeof fileRecord.filePath === 'string'
? fileRecord.filePath
: '';
if (!displayPath) continue;
const added = parseCount(fileRecord.additions);
const removed = parseCount(fileRecord.deletions);
const existing = entriesByPath.get(displayPath);
if (existing) {
existing.added = combineCounts(existing.added, added);
existing.removed = combineCounts(existing.removed, removed);
continue;
}
entriesByPath.set(displayPath, {
file: fileRecord,
path: displayPath,
name: displayPath.split('/').pop() || displayPath,
added,
removed,
});
}
return Array.from(entriesByPath.values());
};
export const ApplyPatchFileButtons = ({
animate = true,
metadata,
onFileClick,
openDiffLabel,
showFileIcons = true,
textClassName,
}: {
animate?: boolean;
metadata: Record<string, unknown> | undefined;
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void;
openDiffLabel: string;
showFileIcons?: boolean;
textClassName?: string;
}): React.ReactNode => {
const entries = getApplyPatchFileEntries(metadata);
if (entries.length <= 1) return null;
return (
<>
{entries.map((entry) => {
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
const content = (
<>
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
<Text
variant={animate ? 'generate-effect' : 'static'}
className={cn('min-w-0 max-w-full truncate', textClassName)}
style={{ color: 'var(--tools-description)' }}
title={entry.path}
>
{entry.name}
</Text>
{hasPerFileDiff ? (
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
<span style={{ color: 'var(--tools-description)' }}>/</span>
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
</span>
) : null}
</>
);
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
const actionLabel = `${openDiffLabel}: ${entry.path}`;
return canOpen ? (
<Button
key={entry.path}
variant="ghost"
size="xs"
className={cn('min-w-0 max-w-full gap-1 normal-case font-normal tracking-normal', textClassName)}
aria-label={actionLabel}
title={actionLabel}
onClick={(event) => onFileClick(entry.file, event)}
>
{content}
</Button>
) : (
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', textClassName)} style={{ color: 'var(--tools-description)' }}>
{content}
</span>
);
})}
</>
);
};
@@ -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)' }}>
@@ -4,6 +4,7 @@ import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
import { tryParseJsonOutput } from '../toolRenderers';
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
import { getToolDescriptionFallback } from './toolRenderUtils';
describe('getToolOutput', () => {
test('prefers authoritative state output', () => {
@@ -64,3 +65,15 @@ describe('OpenChamber tool output', () => {
expect(tryParseJsonOutput(JSON.stringify(result))).toEqual({ data: result, isJson: true });
});
});
describe('getToolDescriptionFallback', () => {
test('uses the glob pattern when the provided description and title are empty', () => {
expect(getToolDescriptionFallback('glob', '', { pattern: 'packages/electron/README.md' }))
.toBe('packages/electron/README.md');
});
test('prefers an existing glob description over the pattern', () => {
expect(getToolDescriptionFallback('glob', 'Electron docs', { pattern: 'packages/electron/README.md' }))
.toBe('Electron docs');
});
});
@@ -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';
@@ -53,11 +54,22 @@ import {
} from './taskToolModel';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { useI18n } from '@/lib/i18n';
import { getApplyPatchFilePath, getDiffPatchEntries, getPatchText, getPrimaryToolPath, type DiffPatchEntry } from './toolDiffUtils';
import {
extractFirstChangedLineFromDiff,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getPatchText,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
type DiffPatchEntry,
} from './toolDiffUtils';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
import { toAbsoluteFilePath } from '@/lib/path-utils';
import { getToolDescriptionFallback } from './toolRenderUtils';
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
@@ -76,103 +88,6 @@ interface ToolPartProps {
animateTailText?: boolean;
}
const getMultiFileDescription = (
metadata: Record<string, unknown> | undefined,
animate = true,
showFileIcons = true,
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void,
): React.ReactNode => {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
if (files.length <= 1) return null;
const parseCount = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.trunc(value));
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return Math.max(0, parsed);
}
}
return null;
};
const combineCounts = (base: number | null, incoming: number | null): number | null => {
if (base === null) return incoming;
if (incoming === null) return base;
return base + incoming;
};
const entriesByPath = new Map<string, { file: Record<string, unknown>; path: string; name: string; added: number | null; removed: number | null }>();
for (const file of files) {
if (!file || typeof file !== 'object') continue;
const fileObj = file as Record<string, unknown> & { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown };
const filePath = fileObj.relativePath || fileObj.filePath || '';
if (!filePath) continue;
const fileName = filePath.split('/').pop() || filePath;
const added = parseCount(fileObj.additions);
const removed = parseCount(fileObj.deletions);
const existing = entriesByPath.get(filePath);
if (existing) {
existing.added = combineCounts(existing.added, added);
existing.removed = combineCounts(existing.removed, removed);
continue;
}
entriesByPath.set(filePath, { file: fileObj, path: filePath, name: fileName, added, removed });
}
const entries = Array.from(entriesByPath.values());
return (
<>
{entries.map((entry) => {
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
const content = (
<>
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
<Text
variant={animate ? 'generate-effect' : 'static'}
className={cn('min-w-0 max-w-full truncate', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }}
title={entry.path}
>
{entry.name}
</Text>
{hasPerFileDiff ? (
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
<span style={{ color: 'var(--tools-description)' }}>/</span>
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
</span>
) : null}
</>
);
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
return canOpen ? (
<button
key={entry.path}
type="button"
className={cn('inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }}
onClick={(event) => onFileClick(entry.file, event)}
onKeyDown={(event) => event.stopPropagation()}
>
{content}
</button>
) : (
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', TOOL_ROW_DESCRIPTION_CLASS)} style={{ color: 'var(--tools-description)' }}>
{content}
</span>
);
})}
</>
);
};
const normalizeToolName = (toolName: string | undefined | null): string => {
if (typeof toolName !== 'string') {
return '';
@@ -324,54 +239,6 @@ const parseWriteLineCount = (input?: Record<string, unknown>): number | null =>
return lines;
};
const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
if (!diffText || typeof diffText !== 'string') {
return undefined;
}
const lines = diffText.split('\n');
let currentNewLine: number | undefined;
let firstHunkStart: number | undefined;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
if (hunkMatch) {
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
if (Number.isFinite(parsed)) {
currentNewLine = Math.max(1, parsed);
if (!Number.isFinite(firstHunkStart)) {
firstHunkStart = currentNewLine;
}
}
continue;
}
if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) {
continue;
}
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
continue;
}
if (line.startsWith('+')) {
return currentNewLine;
}
if (line.startsWith(' ')) {
currentNewLine += 1;
continue;
}
if (line.startsWith('-') || line.startsWith('\\')) {
continue;
}
}
return firstHunkStart;
};
const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => {
const normalizedContent = content.replace(/\r\n/g, '\n');
if (!normalizedContent.trim()) {
@@ -398,75 +265,6 @@ const buildWritePreviewPatch = (filePath: string | undefined, content: string):
].join('\n');
};
const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record<string, unknown>): number | undefined => {
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
return undefined;
}
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
if (topLevelPatch) {
const line = extractFirstChangedLineFromDiff(topLevelPatch);
if (Number.isFinite(line)) {
return line;
}
}
const files = Array.isArray(metadata.files) ? metadata.files : [];
const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined;
const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
if (filePatch) {
const line = extractFirstChangedLineFromDiff(filePatch);
if (Number.isFinite(line)) {
return line;
}
}
return undefined;
};
const getPrimaryDiffFromMetadata = (
tool: string,
metadata?: Record<string, unknown>,
preferredPath?: string,
): string | undefined => {
if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) {
return undefined;
}
const files = Array.isArray(metadata.files) ? metadata.files : [];
if (files.length > 0) {
const preferred = typeof preferredPath === 'string' && preferredPath.length > 0
? preferredPath
: undefined;
const matched = preferred
? files.find((file) => {
if (!file || typeof file !== 'object') {
return false;
}
const candidate = file as { relativePath?: unknown; filePath?: unknown; movePath?: unknown };
return candidate.relativePath === preferred
|| candidate.filePath === preferred
|| candidate.movePath === preferred;
})
: files[0];
if (matched && typeof matched === 'object') {
const patch = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch)
?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff);
if (patch) {
return patch;
}
}
}
const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff);
if (topLevelPatch) {
return topLevelPatch;
}
return undefined;
};
const normalizeDisplayPath = (value: string): string => {
const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/');
if (!trimmed || trimmed === '/') {
@@ -760,7 +558,7 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDi
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
return getToolDescriptionFallback(part.tool, desc, input);
};
interface ToolScrollableSectionProps {
@@ -1122,7 +920,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"
@@ -1145,10 +947,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}
>
@@ -1557,6 +1356,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;
@@ -1662,6 +1462,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();
@@ -2015,6 +1818,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
onShowPopup,
animateTailText = true,
}) => {
const { t } = useI18n();
const state = part.state;
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
const currentDirectory = useEffectiveDirectory() ?? '';
@@ -2309,21 +2113,23 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const runtime = React.useContext(RuntimeAPIContext);
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
const filePath = getApplyPatchFilePath(file);
if (!runtime?.editor || !filePath || file.type === 'delete') {
if (!runtime?.editor) {
return;
}
event.stopPropagation();
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
const targetLine = patch ? extractFirstChangedLineFromDiff(patch) : undefined;
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
if (runtime.runtime.isVSCode && patch) {
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch });
return;
}
void runtime.editor.openFile(absolutePath, targetLine);
const displayPath = typeof file.relativePath === 'string'
? file.relativePath
: typeof file.filePath === 'string'
? getRelativePath(file.filePath, currentDirectory)
: '';
openApplyPatchFileInEditor({
currentDirectory,
diffLabel: `${displayPath} (changes)`,
editor: runtime.editor,
file,
isVSCode: runtime.runtime.isVSCode,
});
};
const handleMainClick = (e: { stopPropagation: () => void }) => {
@@ -2337,15 +2143,15 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
let toolDiff: string | undefined;
if (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit') {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata);
if (typeof filePath === 'string') {
toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath);
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath);
}
} else if (normalizedPartTool === 'apply_patch') {
filePath = getPrimaryToolPath(normalizedPartTool, input, metadata);
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata);
if (typeof filePath === 'string') {
toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath);
targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath);
}
} else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
@@ -2391,57 +2197,76 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{}
<div
className={cn(
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl cursor-pointer',
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center'
)}
onClick={handleMainClick}
onKeyDown={handleMainKeyDown}
role="button"
tabIndex={0}
'group/tool flex gap-1.5 pr-2 pl-px py-1.5 rounded-xl',
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center cursor-pointer',
)}
onClick={isMultiFileApplyPatch ? undefined : handleMainClick}
onKeyDown={isMultiFileApplyPatch ? undefined : handleMainKeyDown}
role={isMultiFileApplyPatch ? undefined : 'button'}
tabIndex={isMultiFileApplyPatch ? undefined : 0}
>
<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"
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
>
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
)}
style={iconStyle}
>
{getToolIcon(normalizedPartTool || part.tool)}
</div>
{}
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
{isMultiFileApplyPatch ? (
<>
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
style={titleStyle}
<Button
variant="ghost"
size="xs"
className="gap-1.5 normal-case"
aria-expanded={isExpanded}
aria-label={displayName}
title={displayName}
onClick={() => onToggle(part.id)}
>
{displayName}
</MinDurationShineText>
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons, runtime?.editor ? openApplyPatchFile : undefined)}
{isExpanded
? <Icon name="arrow-down-s" className="h-3.5 w-3.5" />
: getToolIcon(normalizedPartTool || part.tool)}
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
className={cn(TOOL_ROW_TITLE_CLASS, 'flex-shrink-0')}
style={titleStyle}
>
{displayName}
</MinDurationShineText>
</Button>
<ApplyPatchFileButtons
metadata={metadata}
animate={animateTailText}
showFileIcons={showToolFileIcons}
textClassName={TOOL_ROW_DESCRIPTION_CLASS}
openDiffLabel={t('chat.toolPart.openFileDiff')}
onFileClick={runtime?.editor ? openApplyPatchFile : undefined}
/>
</>
) : (
<>
<div
// 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 flex items-center justify-center transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
)}
style={iconStyle}
>
{getToolIcon(normalizedPartTool || part.tool)}
</div>
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<MinDurationShineText
active={Boolean(isActive && !isError)}
@@ -0,0 +1,33 @@
import type { EditorAPI } from '@/lib/api/types';
import { toAbsoluteFilePath } from '@/lib/path-utils';
import { extractFirstChangedLineFromDiff, getApplyPatchFilePath, getPatchText } from './toolDiffUtils';
export const openApplyPatchFileInEditor = ({
currentDirectory,
diffLabel,
editor,
file,
isVSCode,
}: {
currentDirectory: string;
diffLabel: string;
editor: EditorAPI;
file: Record<string, unknown>;
isVSCode: boolean;
}): boolean => {
const filePath = getApplyPatchFilePath(file);
if (!filePath || file.type === 'delete') {
return false;
}
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
const line = patch ? extractFirstChangedLineFromDiff(patch) : undefined;
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
if (isVSCode && patch) {
void editor.openDiff('', absolutePath, diffLabel, { line, patch });
} else {
void editor.openFile(absolutePath, line);
}
return true;
};
@@ -1,6 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { getApplyPatchFilePath, getDiffPatchEntries, getPrimaryToolPath, getRenderablePatchInfo } from './toolDiffUtils';
import {
getApplyPatchFilePath,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getRenderablePatchInfo,
} from './toolDiffUtils';
const identity = (path: string) => path;
@@ -47,6 +54,34 @@ describe('toolDiffUtils', () => {
})).toBe('/workspace/project/src/second.ts');
});
test('selects the move patch and line from the same non-deleted file', () => {
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
const metadata = {
patch: deletedPatch,
files: [
{
filePath: '/workspace/project/src/deleted.ts',
relativePath: 'src/deleted.ts',
patch: deletedPatch,
type: 'delete',
},
{
filePath: '/workspace/project/src/old.ts',
movePath: '/workspace/project/src/moved.ts',
relativePath: 'src/moved.ts',
patch: movedPatch,
type: 'move',
},
],
};
expect(getPrimaryDiffFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
.toBe(movedPatch);
expect(getFirstChangedLineFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
.toBe(42);
});
test('treats raw apply_patch envelopes as text, not visual diffs', () => {
const entries = getDiffPatchEntries(undefined, [
'*** Begin Patch',
@@ -200,6 +200,113 @@ export const getPrimaryToolPath = (
return null;
};
const supportsDiffMetadata = (toolName: string): boolean => (
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
);
const getMetadataFileForPath = (
metadata: Record<string, unknown>,
preferredPath?: string,
): Record<string, unknown> | undefined => {
const files = Array.isArray(metadata.files) ? metadata.files : [];
if (!preferredPath) {
const first = files[0];
return isRecord(first) ? first : undefined;
}
return files.find((file): file is Record<string, unknown> => (
isRecord(file)
&& (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath)
));
};
export const getPrimaryDiffFromMetadata = (
toolName: string,
metadata?: Record<string, unknown>,
preferredPath?: string,
): string | undefined => {
if (!metadata || !supportsDiffMetadata(toolName)) {
return undefined;
}
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
const filePatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
if (filePatch) {
return filePatch;
}
return getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
};
export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
if (!diffText) {
return undefined;
}
let currentNewLine: number | undefined;
let firstHunkStart: number | undefined;
for (const rawLine of diffText.split('\n')) {
const line = rawLine.replace(/\r$/, '');
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
if (hunkMatch) {
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
if (Number.isFinite(parsed)) {
currentNewLine = Math.max(1, parsed);
firstHunkStart ??= currentNewLine;
}
continue;
}
if (currentNewLine === undefined) {
continue;
}
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
continue;
}
if (line.startsWith('+')) {
return currentNewLine;
}
if (line.startsWith(' ')) {
currentNewLine += 1;
}
}
return firstHunkStart;
};
export const getFirstChangedLineFromMetadata = (
toolName: string,
metadata?: Record<string, unknown>,
preferredPath?: string,
): number | undefined => {
if (!metadata || !supportsDiffMetadata(toolName)) {
return undefined;
}
if (preferredPath) {
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
const matchedPatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
if (matchedPatch) {
const matchedLine = extractFirstChangedLineFromDiff(matchedPatch);
if (matchedLine !== undefined) {
return matchedLine;
}
}
}
const topLevelPatch = getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
if (topLevelPatch) {
const topLevelLine = extractFirstChangedLineFromDiff(topLevelPatch);
if (topLevelLine !== undefined) {
return topLevelLine;
}
}
const firstFile = getMetadataFileForPath(metadata);
const firstPatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined;
};
const normalizeParsedPath = (path: string | undefined): string => {
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
if (!trimmed || trimmed === '/dev/null') {
@@ -29,3 +29,16 @@ export const isStandaloneTool = (toolName: unknown): boolean => {
export const isStaticTool = (toolName: unknown): boolean => {
return STATIC_TOOL_NAMES.has(normalizeToolName(toolName));
};
export const getToolDescriptionFallback = (
toolName: unknown,
description: unknown,
input: Record<string, unknown> | undefined,
): string => {
if (typeof description === 'string' && description.trim().length > 0) {
return description;
}
const globPattern = normalizeToolName(toolName) === 'glob' ? input?.pattern : undefined;
return typeof globPattern === 'string' ? globPattern : '';
};