diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 830a86455..b5680f3ef 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -158,24 +158,35 @@ function SessionRow({ ) : null} - - + {isMobile && ( + + + {projectPath} + + · + {statusLabel} + · + {timeAgo} + {agent.serverLabel ? ( + <> + · + + {agent.serverLabel} + + + ) : null} + + )} + + {!isMobile && ( + <> + {projectPath} - · - {statusLabel} - · - {timeAgo} - {agent.serverLabel ? ( - <> - · - - {agent.serverLabel} - - - ) : null} - - + {statusLabel} + {timeAgo} + + )} {isMobile && agent.requiresAttention ? ( @@ -202,7 +213,6 @@ function SessionTableSection({ {section.title} - @@ -422,22 +432,24 @@ const styles = StyleSheet.create((theme) => ({ }, sectionTitle: { fontSize: theme.fontSize.sm, - fontWeight: '600', + fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, - textTransform: 'uppercase', - letterSpacing: 0.6, - }, - sectionLine: { - flex: 1, - height: StyleSheet.hairlineWidth, - backgroundColor: theme.colors.surface2, }, listCard: { - overflow: 'hidden', - borderRadius: theme.borderRadius.lg, + overflow: { + xs: 'hidden' as const, + md: 'visible' as const, + }, + borderRadius: { + xs: theme.borderRadius.lg, + md: 0, + }, }, rowDivider: { - borderTopWidth: StyleSheet.hairlineWidth, + borderTopWidth: { + xs: StyleSheet.hairlineWidth, + md: 0, + }, borderTopColor: theme.colors.border, }, row: { @@ -445,8 +457,14 @@ const styles = StyleSheet.create((theme) => ({ alignItems: 'center', paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[1], + borderRadius: { + xs: theme.borderRadius.lg, + md: 0, + }, + marginBottom: { + xs: theme.spacing[1], + md: 0, + }, }, rowLeading: { marginRight: theme.spacing[3], @@ -500,6 +518,21 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, opacity: 0.7, }, + columnMeta: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 1, + minWidth: 60, + maxWidth: 200, + marginLeft: theme.spacing[4], + }, + columnMetaFixed: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 0, + width: 72, + textAlign: 'right' as const, + }, badge: { paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], @@ -514,10 +547,8 @@ const styles = StyleSheet.create((theme) => ({ }, badgeText: { fontSize: theme.fontSize.xs, - fontWeight: '600', + fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, - textTransform: 'uppercase', - letterSpacing: 0.4, }, badgeTextWarning: { color: theme.colors.palette.amber[500], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 3a128d789..7f27146c4 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -23,7 +23,8 @@ import { import { router, usePathname } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' -import { ChevronDown, ChevronRight, Plus } from 'lucide-react-native' +import * as Clipboard from 'expo-clipboard' +import { ChevronDown, ChevronRight, MoreVertical, Plus } from 'lucide-react-native' import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' @@ -50,6 +51,12 @@ import { ContextMenuTrigger, useContextMenu, } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' import { SyncedLoader } from '@/components/synced-loader' import { useToast } from '@/contexts/toast-context' import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' @@ -112,6 +119,11 @@ interface WorkspaceRowInnerProps { isArchiving: boolean dragHandleProps?: DraggableListDragHandleProps menuController: ReturnType | null + archiveLabel?: string + archiveStatus?: 'idle' | 'pending' | 'success' + archivePendingLabel?: string + onArchive?: () => void + onCopyPath?: () => void } function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null { @@ -601,7 +613,13 @@ function WorkspaceRowInner({ isArchiving, dragHandleProps, menuController, + archiveLabel, + archiveStatus = 'idle', + archivePendingLabel, + onArchive, + onCopyPath, }: WorkspaceRowInnerProps) { + const { theme } = useUnistyles() const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const interaction = useLongPressDragInteraction({ drag, @@ -617,83 +635,89 @@ function WorkspaceRowInner({ onPress() }, [interaction.didLongPressRef, onPress]) - const rowChildren = ( - <> - - - - {workspace.name} - - - - {createdAtLabel ? ( - - {createdAtLabel} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} - - - ) - - const trigger = menuController ? ( - [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - hovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} - > - {rowChildren} - - ) : ( - [ - styles.workspaceRow, - isDragging && styles.workspaceRowDragging, - selected && styles.sidebarRowSelected, - hovered && styles.workspaceRowHovered, - pressed && styles.workspaceRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-workspace-row-${workspace.workspaceKey}`} - > - {rowChildren} - - ) - - const content = trigger - return ( - {content} + [ + styles.workspaceRow, + isDragging && styles.workspaceRowDragging, + selected && styles.sidebarRowSelected, + hovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} + > + + + + {workspace.name} + + + + {workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : createdAtLabel ? ( + + {createdAtLabel} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + {onArchive ? ( + + + + + + {onCopyPath ? ( + + Copy path + + ) : null} + + {archiveLabel ?? 'Archive'} + + + + ) : null} + + ) } -function WorkspaceRowWithMenuContent({ +function WorkspaceRowWithMenu({ workspace, selected, shortcutNumber, @@ -713,7 +737,6 @@ function WorkspaceRowWithMenuContent({ dragHandleProps?: DraggableListDragHandleProps }) { const toast = useToast() - const contextMenu = useContextMenu() const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) const archiveStatus = useCheckoutGitActionsStore((state) => @@ -792,72 +815,29 @@ function WorkspaceRowWithMenuContent({ })() }, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) - return ( - <> - - - - {isWorktree ? 'Archive worktree' : 'Hide from sidebar'} - - - - ) -} + const handleCopyPath = useCallback(() => { + void Clipboard.setStringAsync(workspace.workspaceId) + toast.copied('Path copied') + }, [toast, workspace.workspaceId]) -function WorkspaceRowWithMenu({ - workspace, - selected, - shortcutNumber, - showShortcutBadge, - onPress, - drag, - isDragging, - dragHandleProps, -}: { - workspace: SidebarWorkspaceEntry - selected: boolean - shortcutNumber: number | null - showShortcutBadge: boolean - onPress: () => void - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps -}) { return ( - - - + ) } @@ -1751,6 +1731,27 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, flexShrink: 0, }, + diffStatRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + flexShrink: 0, + }, + diffStatAdditions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.green[400], + }, + diffStatDeletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.palette.red[500], + }, + kebabButton: { + padding: 2, + borderRadius: 4, + marginLeft: 2, + }, shortcutBadge: { minWidth: 18, height: 18, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index bbc69dcab..2afda94b0 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -33,6 +33,7 @@ function workspace( name: input.name, status: input.status, activityAt: input.activityAt, + diffStat: null, } } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 671779d7f..712e1d82a 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -18,6 +18,7 @@ export interface SidebarWorkspaceEntry { name: string activityAt: Date | null statusBucket: SidebarStateBucket + diffStat: { additions: number; deletions: number } | null } export interface SidebarProjectEntry { @@ -129,6 +130,7 @@ export function buildSidebarProjectsFromWorkspaces(input: { name: workspace.name, activityAt: workspace.activityAt, statusBucket: workspace.status, + diffStat: workspace.diffStat, } project.workspaces.push(row) diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index b4416b3d6..a6f5e0e7f 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -18,6 +18,7 @@ describe('workspace source of truth consumption', () => { name: 'feat/workspace-sot', status: 'running', activityAt: new Date('2026-03-01T00:00:00.000Z'), + diffStat: null, } const header = resolveWorkspaceHeader({ workspace }) diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c07ab1dab..1bbd1652b 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -120,6 +120,7 @@ export interface WorkspaceDescriptor { name: string; status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; + diffStat: { additions: number; deletions: number } | null; } export function normalizeWorkspaceDescriptor( @@ -137,6 +138,7 @@ export function normalizeWorkspaceDescriptor( status: payload.status, activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, + diffStat: payload.diffStat ?? null, }; } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index d1cba0604..bb733e05f 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -14,6 +14,7 @@ function workspace(overrides: Partial = {}): SidebarWorks name: 'paseo', activityAt: null, statusBucket: 'done', + diffStat: null, ...overrides, } } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 32155f78e..60c431676 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -12,6 +12,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { name: cwd, activityAt: null, statusBucket: "done", + diffStat: null, }; } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 30a805885..fcc3080c3 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1415,6 +1415,16 @@ export class ClaudeAgentClient implements AgentClient { } catch { this.claudePath = null; } + if (this.claudePath) { + try { + const version = execSync(`${this.claudePath} --version`, { encoding: "utf8" }).trim(); + this.logger.info({ claudePath: this.claudePath, version }, "Resolved Claude binary"); + } catch { + this.logger.info({ claudePath: this.claudePath }, "Resolved Claude binary (version unknown)"); + } + } else { + this.logger.warn("Claude binary not found via 'which claude'; SDK will use bundled binary"); + } } async createSession(config: AgentSessionConfig): Promise { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d1d97134d..67d26e789 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -137,6 +137,7 @@ import { import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-bootstrap.js' import { getCheckoutDiff, + getCheckoutShortstat, getCheckoutStatus, listBranchSuggestions, NotGitRepoError, @@ -5253,6 +5254,13 @@ export class Session { // Fall back to the persisted label if checkout metadata is unavailable. } + let diffStat: { additions: number; deletions: number } | null = null + try { + diffStat = await getCheckoutShortstat(workspace.cwd) + } catch { + // Non-critical — leave null on failure. + } + return { id: workspace.workspaceId, projectId: workspace.projectId, @@ -5263,6 +5271,7 @@ export class Session { name: displayName, status: 'done', activityAt: null, + diffStat, } } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 38a281e09..fb215f394 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1490,6 +1490,10 @@ export const WorkspaceDescriptorPayloadSchema = z.object({ name: z.string(), status: WorkspaceStateBucketSchema, activityAt: z.string().nullable(), + diffStat: z.object({ + additions: z.number(), + deletions: z.number(), + }).nullable().optional(), }) export const AgentUpdateMessageSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 5f9082b8f..b2a876cf2 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1087,6 +1087,77 @@ export async function getCheckoutStatusLite( }; } +export interface CheckoutShortstat { + additions: number; + deletions: number; +} + +export async function getCheckoutShortstat( + cwd: string, + context?: CheckoutContext +): Promise { + try { + await requireGitRepo(cwd); + } catch { + return null; + } + + const configured = await getConfiguredBaseRefForCwd(cwd, context); + const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); + if (!baseRef) { + return null; + } + + const currentBranch = await getCurrentBranch(cwd); + if (currentBranch === baseRef) { + return null; + } + + let mergeBase: string; + try { + const { stdout } = await execAsync(`git merge-base HEAD ${baseRef}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + mergeBase = stdout.trim(); + if (!mergeBase) { + return null; + } + } catch { + return null; + } + + try { + const { stdout } = await execAsync(`git diff --shortstat ${mergeBase}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const text = stdout.trim(); + if (!text) { + return null; + } + + let additions = 0; + let deletions = 0; + const addMatch = text.match(/(\d+)\s+insertion/); + if (addMatch) { + additions = Number.parseInt(addMatch[1]!, 10); + } + const delMatch = text.match(/(\d+)\s+deletion/); + if (delMatch) { + deletions = Number.parseInt(delMatch[1]!, 10); + } + + if (additions === 0 && deletions === 0) { + return null; + } + + return { additions, deletions }; + } catch { + return null; + } +} + export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare,