feat: add diff stats and archive actions to workspace sidebar

This commit is contained in:
Mohamed Boudra
2026-03-09 16:38:36 +07:00
parent 7f11b93e0f
commit a15b52efc8
12 changed files with 304 additions and 170 deletions
+64 -33
View File
@@ -158,24 +158,35 @@ function SessionRow({
<SessionBadge label="Attention" tone="danger" />
) : null}
</View>
<View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{isMobile && (
<View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
</View>
{!isMobile && (
<>
<Text style={styles.columnMeta} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
</View>
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</>
)}
{isMobile && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
<SessionBadge label="Attention" tone="danger" />
@@ -202,7 +213,6 @@ function SessionTableSection({
<View style={styles.sectionBlock}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.listCard}>
@@ -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],
@@ -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<typeof useContextMenu> | 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 = (
<>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel}
</Text>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</>
)
const trigger = menuController ? (
<ContextMenuTrigger
enabledOnMobile={false}
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
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}
</ContextMenuTrigger>
) : (
<Pressable
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
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}
</Pressable>
)
const content = trigger
return (
<View style={styles.workspaceRowContainer}>
{content}
<Pressable
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
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}`}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel}
</Text>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
{onArchive ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={styles.kebabButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
<MoreVertical size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
status={archiveStatus}
pendingLabel={archivePendingLabel}
destructive
onSelect={onArchive}
>
{archiveLabel ?? 'Archive'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
</Pressable>
</View>
)
}
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 (
<>
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
dragHandleProps={dragHandleProps}
menuController={contextMenu}
/>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
pendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
destructive
onSelect={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
>
{isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
</ContextMenuItem>
</ContextMenuContent>
</>
)
}
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 (
<ContextMenu>
<WorkspaceRowWithMenuContent
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
</ContextMenu>
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyPath={handleCopyPath}
/>
)
}
@@ -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,
@@ -33,6 +33,7 @@ function workspace(
name: input.name,
status: input.status,
activityAt: input.activityAt,
diffStat: null,
}
}
@@ -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)
@@ -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 })
+2
View File
@@ -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,
};
}
@@ -14,6 +14,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
name: 'paseo',
activityAt: null,
statusBucket: 'done',
diffStat: null,
...overrides,
}
}
@@ -12,6 +12,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
name: cwd,
activityAt: null,
statusBucket: "done",
diffStat: null,
};
}
@@ -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<AgentSession> {
+9
View File
@@ -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,
}
}
+4
View File
@@ -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({
+71
View File
@@ -1087,6 +1087,77 @@ export async function getCheckoutStatusLite(
};
}
export interface CheckoutShortstat {
additions: number;
deletions: number;
}
export async function getCheckoutShortstat(
cwd: string,
context?: CheckoutContext
): Promise<CheckoutShortstat | null> {
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,