diff --git a/packages/app/01-diff-sidebar-open.png b/packages/app/01-diff-sidebar-open.png new file mode 100644 index 000000000..c8f5b1fb2 Binary files /dev/null and b/packages/app/01-diff-sidebar-open.png differ diff --git a/packages/app/02-after-vertical-scroll.png b/packages/app/02-after-vertical-scroll.png new file mode 100644 index 000000000..6b9eb9d94 Binary files /dev/null and b/packages/app/02-after-vertical-scroll.png differ diff --git a/packages/app/02-tabs-work.png b/packages/app/02-tabs-work.png new file mode 100644 index 000000000..3b3b9bcbf Binary files /dev/null and b/packages/app/02-tabs-work.png differ diff --git a/packages/app/03-after-horizontal-scroll-right.png b/packages/app/03-after-horizontal-scroll-right.png new file mode 100644 index 000000000..695da0310 Binary files /dev/null and b/packages/app/03-after-horizontal-scroll-right.png differ diff --git a/packages/app/03-after-vertical-scroll.png b/packages/app/03-after-vertical-scroll.png new file mode 100644 index 000000000..c8f5b1fb2 Binary files /dev/null and b/packages/app/03-after-vertical-scroll.png differ diff --git a/packages/app/04-after-horizontal-scroll-right.png b/packages/app/04-after-horizontal-scroll-right.png new file mode 100644 index 000000000..43bd982f3 Binary files /dev/null and b/packages/app/04-after-horizontal-scroll-right.png differ diff --git a/packages/app/04-back-at-left-edge.png b/packages/app/04-back-at-left-edge.png new file mode 100644 index 000000000..62c1fb6f9 Binary files /dev/null and b/packages/app/04-back-at-left-edge.png differ diff --git a/packages/app/05-back-at-left-edge.png b/packages/app/05-back-at-left-edge.png new file mode 100644 index 000000000..441064602 Binary files /dev/null and b/packages/app/05-back-at-left-edge.png differ diff --git a/packages/app/app-launched.png b/packages/app/app-launched.png new file mode 100644 index 000000000..0e9479fd8 Binary files /dev/null and b/packages/app/app-launched.png differ diff --git a/packages/app/current-state.png b/packages/app/current-state.png new file mode 100644 index 000000000..2cba701bd Binary files /dev/null and b/packages/app/current-state.png differ diff --git a/packages/app/dev-client.png b/packages/app/dev-client.png new file mode 100644 index 000000000..1892ecded Binary files /dev/null and b/packages/app/dev-client.png differ diff --git a/packages/app/diff-sidebar-open.png b/packages/app/diff-sidebar-open.png new file mode 100644 index 000000000..22598ba9e Binary files /dev/null and b/packages/app/diff-sidebar-open.png differ diff --git a/packages/app/fail3.png b/packages/app/fail3.png new file mode 100644 index 000000000..009034bd9 Binary files /dev/null and b/packages/app/fail3.png differ diff --git a/packages/app/launch-screen.png b/packages/app/launch-screen.png new file mode 100644 index 000000000..06f612d01 Binary files /dev/null and b/packages/app/launch-screen.png differ diff --git a/packages/app/maestro-fail.png b/packages/app/maestro-fail.png new file mode 100644 index 000000000..f04af94bc Binary files /dev/null and b/packages/app/maestro-fail.png differ diff --git a/packages/app/maestro-fail2.png b/packages/app/maestro-fail2.png new file mode 100644 index 000000000..82e827c53 Binary files /dev/null and b/packages/app/maestro-fail2.png differ diff --git a/packages/app/maestro/flows/launch.yaml b/packages/app/maestro/flows/launch.yaml new file mode 100644 index 000000000..3183b3d34 --- /dev/null +++ b/packages/app/maestro/flows/launch.yaml @@ -0,0 +1,26 @@ +appId: com.moboudra.paseo +--- +# Reusable flow to handle dev client screens after launchApp +# Works with both release builds (no-ops) and dev builds + +# Handle dev launcher - tap localhost server (dev builds only) +- tapOn: + text: ".*localhost.*" + optional: true + +# Dismiss dev menu intro modal +- runFlow: + when: + visible: "Continue" + commands: + - tapOn: "Continue" + +# Dismiss dev menu if it opens +- runFlow: + when: + visible: "Go home" + commands: + - tapOn: + point: "50%,20%" + +- assertVisible: "New Agent" diff --git a/packages/app/maestro/poc.yaml b/packages/app/maestro/poc.yaml new file mode 100644 index 000000000..0aece98ce --- /dev/null +++ b/packages/app/maestro/poc.yaml @@ -0,0 +1,11 @@ +appId: com.moboudra.paseo +--- +- launchApp: + clearState: true +- runFlow: flows/launch.yaml +- takeScreenshot: app-launched +- swipe: + direction: RIGHT + duration: 300 +- assertVisible: "Settings" +- tapOn: "Settings" diff --git a/packages/app/maestro/sidebar-swipe-bug.yaml b/packages/app/maestro/sidebar-swipe-bug.yaml new file mode 100644 index 000000000..efccecf48 --- /dev/null +++ b/packages/app/maestro/sidebar-swipe-bug.yaml @@ -0,0 +1,74 @@ +appId: com.moboudra.paseo +--- +- launchApp: + clearState: true +- runFlow: flows/launch.yaml + +# Open left sidebar +- swipe: + direction: RIGHT + duration: 300 + +# Tap on an agent conversation +- assertVisible: + text: ".*Build Production APK.*" +- tapOn: + text: ".*Build Production APK.*" + +# Wait for chat to load +- assertVisible: "Message agent..." + +# Swipe right-to-left to reveal git diff sidebar +- swipe: + direction: LEFT + duration: 300 + +# Assert diff sidebar is visible +- assertVisible: "Changes" +- assertVisible: "Files" +- takeScreenshot: 01-diff-sidebar-open + +# TEST 1: Scroll diff vertically down (should work) +# Tap on Files tab to switch, then back to Changes - proves interaction works +- tapOn: "Files" +- assertVisible: "Files" +- tapOn: "Changes" +- assertVisible: "Changes" +- takeScreenshot: 02-tabs-work + +# TEST 2: Scroll diff content vertically (should work) +- swipe: + start: "50%,70%" + end: "50%,30%" + duration: 300 +- assertVisible: "Changes" +- takeScreenshot: 03-after-vertical-scroll + +# TEST 3: Scroll diff horizontally to the right (should work) +- swipe: + start: "80%,50%" + end: "20%,50%" + duration: 300 +- assertVisible: "Changes" +- takeScreenshot: 04-after-horizontal-scroll-right + +# TEST 4: Scroll diff back to left edge (scrollLeft = 0) +- swipe: + start: "20%,50%" + end: "80%,50%" + duration: 300 +- swipe: + start: "20%,50%" + end: "80%,50%" + duration: 300 +- assertVisible: "Changes" +- takeScreenshot: 05-back-at-left-edge + +# TEST 5 (BUG): At scrollLeft 0, swipe right should close sidebar +# Currently fails - the gesture bounces the scrollview instead +- swipe: + start: "10%,50%" + end: "90%,50%" + duration: 300 +- assertVisible: "Message agent..." +- takeScreenshot: 06-sidebar-closed diff --git a/packages/app/manual-launch.png b/packages/app/manual-launch.png new file mode 100644 index 000000000..6e550f01a Binary files /dev/null and b/packages/app/manual-launch.png differ diff --git a/packages/app/settings-screen.png b/packages/app/settings-screen.png new file mode 100644 index 000000000..7bbe64541 Binary files /dev/null and b/packages/app/settings-screen.png differ diff --git a/packages/app/sidebar-fail.png b/packages/app/sidebar-fail.png new file mode 100644 index 000000000..db49f5923 Binary files /dev/null and b/packages/app/sidebar-fail.png differ diff --git a/packages/app/sidebar-fail2.png b/packages/app/sidebar-fail2.png new file mode 100644 index 000000000..6bbb11319 Binary files /dev/null and b/packages/app/sidebar-fail2.png differ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 0feabcd57..aa2783b43 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -258,16 +258,13 @@ export default function RootLayout() { screenOptions={{ headerShown: false, animation: "none", - gestureEnabled: true, - gestureDirection: "horizontal", - fullScreenGestureEnabled: true, }} > - - + + diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/app/agents.tsx index 418226b3a..76ed37aa1 100644 --- a/packages/app/src/app/agents.tsx +++ b/packages/app/src/app/agents.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState, useCallback, useEffect } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; @@ -8,6 +8,21 @@ import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; export default function AgentsScreen() { const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); + // Track user-initiated refresh to avoid showing spinner on background revalidation + const [isManualRefresh, setIsManualRefresh] = useState(false); + + const handleRefresh = useCallback(() => { + setIsManualRefresh(true); + refreshAll(); + }, [refreshAll]); + + // Reset manual refresh flag when revalidation completes + useEffect(() => { + if (!isRevalidating && isManualRefresh) { + setIsManualRefresh(false); + } + }, [isRevalidating, isManualRefresh]); + const sortedAgents = useMemo(() => { return [...agents].sort((a, b) => { if (a.requiresAttention && !b.requiresAttention) return -1; @@ -21,8 +36,8 @@ export default function AgentsScreen() { ); diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 773f40311..70a68a4dd 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -13,6 +13,7 @@ import { } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; import { useAppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context"; import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context"; @@ -82,7 +83,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, }, inputUrl: { - fontFamily: "monospace", + fontFamily: Fonts.mono, }, // Host card styles hostCard: { @@ -110,7 +111,7 @@ const styles = StyleSheet.create((theme) => ({ hostUrl: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, - fontFamily: "monospace", + fontFamily: Fonts.mono, }, hostError: { color: theme.colors.palette.red[300], diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index e2da089f5..8a75366a2 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { View, Text, Pressable } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; +import { Fonts } from "@/constants/theme"; import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity'; interface AgentActivityItemProps { @@ -346,7 +347,7 @@ const stylesheet = StyleSheet.create((theme) => ({ code: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, - fontFamily: 'monospace', + fontFamily: Fonts.mono, backgroundColor: theme.colors.muted, padding: theme.spacing[2], borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 0c6b4d2eb..570f31f23 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -15,6 +15,7 @@ import { import Markdown from "react-native-markdown-display"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; import Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from "react-native-reanimated"; import { ChevronDown } from "lucide-react-native"; import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; @@ -1160,7 +1161,7 @@ const permissionStyles = StyleSheet.create((theme) => ({ letterSpacing: 0.5, }, metadataValue: { - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, }, diffSection: { @@ -1174,7 +1175,7 @@ const permissionStyles = StyleSheet.create((theme) => ({ borderWidth: theme.borderWidth[1], }, fileBadgeText: { - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, }, diffWrapper: { @@ -1183,7 +1184,7 @@ const permissionStyles = StyleSheet.create((theme) => ({ overflow: "hidden", }, rawContentText: { - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, lineHeight: 20, }, diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx index 9976b7cc0..c555c9e29 100644 --- a/packages/app/src/components/artifact-drawer.tsx +++ b/packages/app/src/components/artifact-drawer.tsx @@ -8,6 +8,7 @@ import { import { SafeAreaView } from "react-native-safe-area-context"; import { useEffect } from "react"; import { StyleSheet } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; export interface Artifact { id: string; @@ -120,7 +121,7 @@ const styles = StyleSheet.create((theme) => ({ codeText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontFamily: "monospace", + fontFamily: Fonts.mono, }, metadataContainer: { backgroundColor: theme.colors.card, @@ -148,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, flex: 1, - fontFamily: "monospace", + fontFamily: Fonts.mono, }, })); diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 3180d32a9..69cd5716c 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -2,6 +2,7 @@ import React from "react"; import { View, Text } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; import type { DiffLine } from "@/utils/tool-call-parsers"; interface DiffViewerProps { @@ -80,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], }, lineText: { - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, }, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index e1b9cf6e1..cb15b09ff 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -66,8 +66,10 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { Gesture.Pan() .withRef(closeGestureRef) .enabled(isMobile && isOpen) - // Only activate after 15px horizontal movement (creates deadzone for taps) - .activeOffsetX([-15, 15]) + // Only activate on rightward swipe (positive X), fail on leftward or vertical + // This allows ScrollViews using waitFor to scroll left normally + .activeOffsetX(15) + .failOffsetX(-10) .failOffsetY([-10, 10]) .onStart(() => { isGesturing.value = true; diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 2e9275512..8d7690c94 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -18,8 +18,10 @@ import { useWindowDimensions, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import { File as FSFile, Paths } from "expo-file-system"; +import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; import { BottomSheetModal, @@ -169,7 +171,16 @@ export function FileExplorerPane({ status: "downloading" | "complete" | "error"; fileName: string; message?: string; + progress?: { + percent: number; + bytesWritten: number; + totalBytes: number; + speed: number; + eta: number; + }; } | null>(null); + const downloadStartTimeRef = useRef(0); + const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null); const downloadToastTimeoutRef = useRef | null>(null); const agentIdRef = useRef(agentId); const viewModeRef = useRef(viewMode); @@ -467,21 +478,59 @@ export function FileExplorerPane({ return; } + downloadStartTimeRef.current = Date.now(); + lastProgressRef.current = null; showDownloadToast({ status: "downloading", fileName: displayName }); const targetFile = resolveDownloadTargetFile(fileName); - const downloadedFile = await FSFile.downloadFileAsync( + const downloadResumable = LegacyFileSystem.createDownloadResumable( downloadUrl, - targetFile, + targetFile.uri, downloadTarget.authHeader ? { headers: { Authorization: downloadTarget.authHeader } } - : undefined + : undefined, + (data) => { + const now = Date.now(); + const { totalBytesWritten, totalBytesExpectedToWrite } = data; + + if (totalBytesExpectedToWrite <= 0) { + return; + } + + const percent = totalBytesWritten / totalBytesExpectedToWrite; + const elapsed = (now - downloadStartTimeRef.current) / 1000; + const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0; + const remaining = totalBytesExpectedToWrite - totalBytesWritten; + const eta = speed > 0 ? remaining / speed : 0; + + lastProgressRef.current = { bytes: totalBytesWritten, time: now }; + + setDownloadToast((prev) => + prev?.status === "downloading" + ? { + ...prev, + progress: { + percent, + bytesWritten: totalBytesWritten, + totalBytes: totalBytesExpectedToWrite, + speed, + eta, + }, + } + : prev + ); + } ); + const result = await downloadResumable.downloadAsync(); + if (!result) { + throw new Error("Download was cancelled."); + } + showDownloadToast({ status: "complete", fileName: displayName }); if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(downloadedFile.uri, { + await Sharing.shareAsync(result.uri, { mimeType: tokenResponse.mimeType ?? undefined, dialogTitle: fileName ? `Share ${fileName}` : "Share file", }); @@ -947,11 +996,23 @@ export function FileExplorerPane({ {downloadToast.status === "downloading" - ? "Downloading..." + ? downloadToast.progress + ? `${Math.round(downloadToast.progress.percent * 100)}% · ${formatSpeed(downloadToast.progress.speed)} · ${formatEta(downloadToast.progress.eta)}` + : "Starting..." : downloadToast.status === "complete" ? "Download complete" : downloadToast.message ?? "Download failed"} + {downloadToast.status === "downloading" && downloadToast.progress && ( + + + + )} {downloadToast.status !== "downloading" && ( ({ flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, - fontFamily: "monospace", + fontFamily: Fonts.mono, }, backButton: { padding: theme.spacing[1], @@ -1379,7 +1462,7 @@ const styles = StyleSheet.create((theme) => ({ }, codeText: { color: theme.colors.foreground, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, flexShrink: 0, }, @@ -1510,6 +1593,18 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, fontSize: theme.fontSize.xs, }, + downloadProgressBar: { + height: 3, + backgroundColor: theme.colors.muted, + borderRadius: theme.borderRadius.full, + marginTop: theme.spacing[1], + overflow: "hidden", + }, + downloadProgressFill: { + height: "100%", + backgroundColor: theme.colors.primary, + borderRadius: theme.borderRadius.full, + }, downloadToastDismiss: { padding: theme.spacing[1], }, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 9ecbb753a..a7ef832e1 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useId } from "react"; +import { useState, useCallback, useEffect, useId, useRef } from "react"; import { View, Text, @@ -8,7 +8,7 @@ import { type NativeSyntheticEvent, type NativeScrollEvent, } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; +import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; import { useSessionStore } from "@/stores/session-store"; @@ -19,6 +19,8 @@ import { type HighlightToken, } from "@/hooks/use-highlighted-diff-query"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { Fonts } from "@/constants/theme"; type HighlightStyle = NonNullable; @@ -120,8 +122,19 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti const { theme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const horizontalScroll = useHorizontalScrollOptional(); const scrollId = useId(); + const scrollViewRef = useRef(null); + + // Get the close gesture ref from animation context (may not be available outside sidebar) + let closeGestureRef: React.MutableRefObject | undefined; + try { + const animation = useExplorerSidebarAnimation(); + closeGestureRef = animation.closeGestureRef; + } catch { + // Not inside ExplorerSidebarAnimationProvider, which is fine + } const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); @@ -139,9 +152,12 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti const handleScroll = useCallback( (event: NativeSyntheticEvent) => { - if (!horizontalScroll) return; const offsetX = event.nativeEvent.contentOffset.x; - horizontalScroll.registerScrollOffset(scrollId, offsetX); + // Track if we're at the left edge (with small threshold for float precision) + setIsAtLeftEdge(offsetX <= 1); + if (horizontalScroll) { + horizontalScroll.registerScrollOffset(scrollId, offsetX); + } }, [horizontalScroll, scrollId] ); @@ -183,6 +199,7 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti {isExpanded && ( setScrollViewWidth(e.nativeEvent.layout.width)} + // When at left edge, wait for close gesture to fail before scrolling. + // The close gesture fails quickly on leftward swipes (failOffsetX=-10), + // so scrolling left works normally. On rightward swipes, close gesture + // activates and closes the sidebar. + waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > 0 && { minWidth: scrollViewWidth }]}> {file.hunks.map((hunk, hunkIndex) => @@ -217,6 +239,20 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { serverId, agentId, }); + // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches + const [isManualRefresh, setIsManualRefresh] = useState(false); + + const handleRefresh = useCallback(() => { + setIsManualRefresh(true); + refresh(); + }, [refresh]); + + // Reset manual refresh flag when fetch completes + useEffect(() => { + if (!isFetching && isManualRefresh) { + setIsManualRefresh(false); + } + }, [isFetching, isManualRefresh]); const agentExists = useSessionStore((state) => state.sessions[serverId]?.agents?.has(agentId) ?? false @@ -239,8 +275,8 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { testID="git-diff-scroll" refreshControl={ @@ -354,7 +390,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, - fontFamily: "monospace", + fontFamily: Fonts.mono, flex: 1, }, newBadge: { @@ -373,13 +409,13 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.green[400], - fontFamily: "monospace", + fontFamily: Fonts.mono, }, deletions: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.red[500], - fontFamily: "monospace", + fontFamily: Fonts.mono, }, diffContent: { borderTopWidth: theme.borderWidth[1], @@ -398,7 +434,7 @@ const styles = StyleSheet.create((theme) => ({ }, diffLineText: { fontSize: theme.fontSize.xs, - fontFamily: "monospace", + fontFamily: Fonts.mono, color: theme.colors.foreground, }, addLineContainer: { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index cf9aa45d3..56bcc655c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -25,7 +25,7 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles"; -import { Colors } from "@/constants/theme"; +import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, ThoughtStatus } from "@/types/stream"; import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers"; @@ -162,7 +162,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], paddingVertical: 2, borderRadius: theme.borderRadius.sm, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: 13, }, // Used in custom markdownRules for path chip styling @@ -176,7 +176,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ }, pathChipText: { color: theme.colors.secondaryForeground, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: 13, }, })); @@ -554,7 +554,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({ metadataText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, - fontFamily: "monospace", + fontFamily: Fonts.mono, lineHeight: 16, }, })); diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index f0dd93555..b602f470b 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState, useEffect } from "react"; import { View, Pressable, Text, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { @@ -37,6 +37,21 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { isGesturing, } = useSidebarAnimation(); + // Track user-initiated refresh to avoid showing spinner on background revalidation + const [isManualRefresh, setIsManualRefresh] = useState(false); + + const handleRefresh = useCallback(() => { + setIsManualRefresh(true); + refreshAll(); + }, [refreshAll]); + + // Reset manual refresh flag when revalidation completes + useEffect(() => { + if (!isRevalidating && isManualRefresh) { + setIsManualRefresh(false); + } + }, [isRevalidating, isManualRefresh]); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -198,8 +213,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 7635a5523..3467977e2 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -8,6 +8,7 @@ import React, { } from "react"; import { View, Text, Pressable, ScrollView } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; import { BottomSheetModal, BottomSheetScrollView, @@ -765,7 +766,7 @@ const styles = StyleSheet.create((theme) => ({ }, fileBadgeText: { color: theme.colors.foreground, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, }, diffContainer: { @@ -786,7 +787,7 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[2], }, scrollText: { - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, @@ -824,7 +825,7 @@ const styles = StyleSheet.create((theme) => ({ }, metaValue: { color: theme.colors.foreground, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, flex: 1, }, diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index dc3240257..6f0f117a1 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -1,4 +1,5 @@ import type { Theme } from "./theme"; +import { Fonts } from "@/constants/theme"; /** * Creates comprehensive markdown styles for react-native-markdown-display. @@ -135,7 +136,7 @@ export function createMarkdownStyles(theme: Theme) { paddingHorizontal: theme.spacing[1], paddingVertical: 2, borderRadius: theme.borderRadius.sm, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, }, @@ -144,7 +145,7 @@ export function createMarkdownStyles(theme: Theme) { color: theme.colors.secondaryForeground, padding: theme.spacing[3], borderRadius: theme.borderRadius.md, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, marginVertical: theme.spacing[2], }, @@ -156,7 +157,7 @@ export function createMarkdownStyles(theme: Theme) { borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, - fontFamily: "monospace", + fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, marginVertical: theme.spacing[3], }, diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index df9a23527..2babeb2b1 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; import { spawnSync } from "node:child_process"; -import { appendFileSync, existsSync, openSync } from "node:fs"; +import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { FileTaskStore } from "./task-store.js"; import type { AgentType, Task } from "./types.js"; @@ -9,6 +9,14 @@ import type { AgentType, Task } from "./types.js"; const TASKS_DIR = resolve(process.cwd(), ".tasks"); const store = new FileTaskStore(TASKS_DIR); +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf-8").trim(); +} + const program = new Command() .name("task") .description("Minimal task management with dependency tracking") @@ -17,12 +25,23 @@ const program = new Command() "after", ` Examples: - # Create an epic with subtasks (top-down) + # Create an epic with subtasks (hierarchical) task create "Build auth system" task create "Add login endpoint" --parent abc123 task create "Add logout endpoint" --parent abc123 - # Create with dependencies (bottom-up) + # Create with body from stdin (use "-" for body) + cat spec.md | task create "Implement feature" --body - + + # Update task body + task update abc123 --body "New body content" + cat updated-spec.md | task update abc123 --body - + + # Move task to different parent + task move abc123 --parent def456 + task move abc123 --root # make it a root task + + # Create with dependencies (separate from hierarchy) task create "Setup database" task create "Add user model" --deps def456 @@ -33,6 +52,9 @@ Examples: task create "Future feature" --draft task open abc123 # make it actionable + # View task with parent context + task show abc123 + # View the work breakdown task tree abc123 @@ -47,31 +69,41 @@ Examples: task run abc123 task run abc123 --agent codex task run --watch + +Body vs Notes: + The BODY is the task's markdown document - edit it while grooming/defining the task. + NOTES are timestamped entries added during implementation to document progress. + + - While defining a task: edit the body with "task update --body ..." + - While implementing: add notes with "task note ..." + - When done: add a final note explaining what was done, then close ` ); program .command("create ") .description("Create a new task") - .option("-d, --description <text>", "Task description") + .option("-b, --body <text>", "Task body (use '-' to read from stdin)") .option("--deps <ids>", "Comma-separated dependency IDs") - .option("--parent <id>", "Parent task (parent will depend on this new task)") + .option("--parent <id>", "Parent task ID (for hierarchy)") .option("--assignee <agent>", "Agent to assign (claude or codex)") .option("--draft", "Create as draft (not actionable)") .action(async (title, opts) => { + let body = opts.body ?? ""; + if (body === "-") { + body = await readStdin(); + } + const task = await store.create(title, { - description: opts.description, + body, deps: opts.deps ? opts.deps.split(",").map((s: string) => s.trim()) : [], + parentId: opts.parent, status: opts.draft ? "draft" : "open", assignee: opts.assignee as AgentType | undefined, }); - if (opts.parent) { - await store.addDep(opts.parent, task.id); - } - console.log(task.id); }); @@ -80,38 +112,67 @@ program .alias("ls") .description("List all tasks") .option("-s, --status <status>", "Filter by status") + .option("--roots", "Show only root tasks (no parent)") .action(async (opts) => { const tasks = await store.list(); - const filtered = opts.status + let filtered = opts.status ? tasks.filter((t) => t.status === opts.status) : tasks; + if (opts.roots) { + filtered = filtered.filter((t) => !t.parentId); + } + for (const t of filtered) { const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : ""; const assignee = t.assignee ? ` @${t.assignee}` : ""; - console.log(`${t.id} [${t.status}] ${t.title}${assignee}${deps}`); + const parent = t.parentId ? ` ^${t.parentId}` : ""; + console.log(`${t.id} [${t.status}] ${t.title}${assignee}${parent}${deps}`); } }); program .command("show <id>") - .description("Show task details") + .description("Show task details with parent context") .action(async (id) => { const task = await store.get(id); if (!task) { console.error(`Task not found: ${id}`); process.exit(1); } + + // Get ancestors (parent chain from immediate to root) + const ancestors = await store.getAncestors(id); + + // Print ancestors first (root to immediate parent) + if (ancestors.length > 0) { + console.log("# Parent Context\n"); + for (const ancestor of ancestors.reverse()) { + console.log(`## ${ancestor.title} (${ancestor.id}) [${ancestor.status}]`); + if (ancestor.body) { + console.log(`\n${ancestor.body}`); + } + console.log(""); + } + console.log("---\n"); + } + + // Print current task + console.log(`# ${task.title}\n`); console.log(`id: ${task.id}`); - console.log(`title: ${task.title}`); console.log(`status: ${task.status}`); console.log(`created: ${task.created}`); if (task.assignee) { console.log(`assignee: ${task.assignee}`); } - console.log(`deps: [${task.deps.join(", ")}]`); - if (task.description) { - console.log(`\n${task.description}`); + if (task.parentId) { + console.log(`parent: ${task.parentId}`); + } + if (task.deps.length) { + console.log(`deps: [${task.deps.join(", ")}]`); + } + if (task.body) { + console.log(`\n${task.body}`); } if (task.notes.length) { console.log("\n## Notes"); @@ -157,42 +218,65 @@ program program .command("tree <id>") - .description("Show dependency tree") + .description("Show task hierarchy with dependencies") .action(async (id) => { const root = await store.get(id); if (!root) { console.error(`Task not found: ${id}`); process.exit(1); } - console.log(`${root.id} [${root.status}] ${root.title}`); - const tree = await store.getDepTree(id); - const taskMap = new Map(tree.map((t) => [t.id, t])); + // Build a map of all tasks for dependency lookups + const allTasks = await store.list(); + const taskMap = new Map(allTasks.map((t) => [t.id, t])); - const printed = new Set<string>(); - const printDeps = async (taskId: string, prefix: string) => { - const task = await store.get(taskId); - if (!task) return; - - const deps = task.deps.filter((d) => !printed.has(d)); - for (let i = 0; i < deps.length; i++) { - const depId = deps[i]; - const dep = taskMap.get(depId); - if (!dep) continue; - - printed.add(depId); - const isLast = i === deps.length - 1; - const connector = isLast ? "└── " : "├── "; - const childPrefix = isLast ? " " : "│ "; - - console.log( - `${prefix}${connector}${dep.id} [${dep.status}] ${dep.title}` - ); - await printDeps(depId, prefix + childPrefix); + // Print a task line with optional dependency info + const printTask = (task: Task, prefix: string, connector: string) => { + const assignee = task.assignee ? ` @${task.assignee}` : ""; + console.log( + `${prefix}${connector}${task.id} [${task.status}] ${task.title}${assignee}` + ); + // Print dependencies on next line with arrow + if (task.deps.length > 0) { + const depNames = task.deps + .map((depId) => { + const dep = taskMap.get(depId); + return dep ? `${dep.title} (${depId})` : depId; + }) + .join(", "); + const depPrefix = prefix + (connector === "└── " ? " " : "│ "); + console.log(`${depPrefix}→ depends on: ${depNames}`); } }; - await printDeps(id, ""); + // Print root task + const rootAssignee = root.assignee ? ` @${root.assignee}` : ""; + console.log(`${root.id} [${root.status}] ${root.title}${rootAssignee}`); + if (root.deps.length > 0) { + const depNames = root.deps + .map((depId) => { + const dep = taskMap.get(depId); + return dep ? `${dep.title} (${depId})` : depId; + }) + .join(", "); + console.log(`→ depends on: ${depNames}`); + } + + // Recursively print children (hierarchy) + const printChildren = async (parentId: string, prefix: string) => { + const children = await store.getChildren(parentId); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const isLast = i === children.length - 1; + const connector = isLast ? "└── " : "├── "; + const childPrefix = prefix + (isLast ? " " : "│ "); + + printTask(child, prefix, connector); + await printChildren(child.id, childPrefix); + } + }; + + await printChildren(id, ""); }); program @@ -211,6 +295,88 @@ program console.log(`Removed: ${id} -> ${depId}`); }); +program + .command("update <id>") + .description("Update task properties") + .option("-t, --title <text>", "New title") + .option("-b, --body <text>", "New body (use '-' to read from stdin)") + .option("--assignee <agent>", "New assignee (claude or codex)") + .action(async (id, opts) => { + const task = await store.get(id); + if (!task) { + console.error(`Task not found: ${id}`); + process.exit(1); + } + + const changes: Partial<Task> = {}; + + if (opts.title) { + changes.title = opts.title; + } + + if (opts.body !== undefined) { + changes.body = opts.body === "-" ? await readStdin() : opts.body; + } + + if (opts.assignee) { + changes.assignee = opts.assignee as AgentType; + } + + if (Object.keys(changes).length === 0) { + console.error("No changes specified"); + process.exit(1); + } + + await store.update(id, changes); + console.log(`Updated: ${id}`); + }); + +program + .command("move <id>") + .description("Move task to a different parent") + .option("--parent <id>", "New parent task ID") + .option("--root", "Make this a root task (remove parent)") + .action(async (id, opts) => { + if (!opts.parent && !opts.root) { + console.error("Must specify --parent <id> or --root"); + process.exit(1); + } + + if (opts.parent && opts.root) { + console.error("Cannot specify both --parent and --root"); + process.exit(1); + } + + await store.setParent(id, opts.root ? null : opts.parent); + if (opts.root) { + console.log(`${id} is now a root task`); + } else { + console.log(`${id} moved to parent ${opts.parent}`); + } + }); + +program + .command("children <id>") + .description("List direct children of a task") + .action(async (id) => { + const task = await store.get(id); + if (!task) { + console.error(`Task not found: ${id}`); + process.exit(1); + } + + const children = await store.getChildren(id); + if (children.length === 0) { + console.log("No children"); + return; + } + + for (const child of children) { + const assignee = child.assignee ? ` @${child.assignee}` : ""; + console.log(`${child.id} [${child.status}] ${child.title}${assignee}`); + } + }); + program .command("note <id> <content>") .description("Add a timestamped note") @@ -251,38 +417,52 @@ async function makePrompt( ): Promise<string> { const scopeArg = scopeId ? ` --scope ${scopeId}` : ""; + // Build parent context from ancestor chain + const ancestors = await store.getAncestors(task.id); + let parentContext = ""; + if (ancestors.length > 0) { + parentContext = "# Parent Context\n\n"; + for (const ancestor of ancestors.reverse()) { + parentContext += `## ${ancestor.title} (${ancestor.id})\n`; + if (ancestor.body) { + parentContext += `\n${ancestor.body}\n`; + } + parentContext += "\n"; + } + parentContext += "---\n\n"; + } + let scopeContext = ""; - if (scopeId) { + if (scopeId && !ancestors.some((a) => a.id === scopeId)) { const scope = await store.get(scopeId); if (scope) { scopeContext = `Scope: ${scope.title} (${scopeId}) -${scope.description ? `\n${scope.description}\n` : ""}`; +${scope.body ? `\n${scope.body}\n` : ""}`; } } return `Working directory: ${process.cwd()} -${scopeContext} ---- +${scopeContext}${parentContext} +# YOUR TASK (${task.id}): ${task.title} -YOUR TASK (${task.id}): ${task.title} - -${task.description || "(no description)"} +${task.body || "(no body)"} --- STEPS: 1. UNDERSTAND CONTEXT FIRST - Before any implementation: - - Run \`task tree ${task.id}\` to see the full dependency graph + - Run \`task show ${task.id}\` to see full context with parent chain + - Run \`task children ${task.id}\` to see subtasks if any - Run \`task closed${scopeArg}\` to see completed sibling tasks - - Run \`task show <id>\` on completed tasks to read their notes - Understand what's been done, what decisions were made, what's planned 2. Implement the task described above 3. Add a note documenting what you did: \`task note ${task.id} "what you did"\` 4. Mark complete: \`task close ${task.id}\` COMMANDS: -- \`task tree <id>\` - see dependency graph from any task -- \`task show <id>\` - view task details and notes +- \`task show <id>\` - view task details with parent context +- \`task children <id>\` - list subtasks +- \`task update <id> --body "..."\` - update task body (for grooming/clarifying) - \`task closed${scopeArg}\` - list completed tasks in scope - \`task note ${task.id} "content"\` - add a note to your task - \`task close ${task.id}\` - mark your task done diff --git a/packages/server/src/tasks/task-store.test.ts b/packages/server/src/tasks/task-store.test.ts index f3c6500a4..acf047832 100644 --- a/packages/server/src/tasks/task-store.test.ts +++ b/packages/server/src/tasks/task-store.test.ts @@ -25,7 +25,8 @@ describe("FileTaskStore", () => { expect(task.title).toBe("My first task"); expect(task.status).toBe("open"); expect(task.deps).toEqual([]); - expect(task.description).toBe(""); + expect(task.parentId).toBeUndefined(); + expect(task.body).toBe(""); expect(task.notes).toEqual([]); expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(task.assignee).toBeUndefined(); @@ -47,16 +48,29 @@ describe("FileTaskStore", () => { expect(task.deps).toEqual([dep1.id, dep2.id]); }); - it("creates a task with description", async () => { - const task = await store.create("Task with desc", { - description: "This is a **long** description\n\nWith multiple lines.", + it("creates a task with body", async () => { + const task = await store.create("Task with body", { + body: "This is a **long** body\n\nWith multiple lines.", }); - expect(task.description).toBe( - "This is a **long** description\n\nWith multiple lines." + expect(task.body).toBe( + "This is a **long** body\n\nWith multiple lines." ); }); + it("creates a task with parentId", async () => { + const parent = await store.create("Parent task"); + const child = await store.create("Child task", { parentId: parent.id }); + + expect(child.parentId).toBe(parent.id); + }); + + it("throws when creating task with non-existent parent", async () => { + await expect( + store.create("Child", { parentId: "nonexistent" }) + ).rejects.toThrow("Parent task not found"); + }); + it("creates a task with assignee", async () => { const task = await store.create("Task for Claude", { assignee: "claude", @@ -138,13 +152,13 @@ describe("FileTaskStore", () => { expect(updated.id).toBe(task.id); }); - it("updates task description", async () => { + it("updates task body", async () => { const task = await store.create("Task"); const updated = await store.update(task.id, { - description: "New description", + body: "New body", }); - expect(updated.description).toBe("New description"); + expect(updated.body).toBe("New body"); }); it("updates task assignee", async () => { @@ -656,7 +670,7 @@ describe("FileTaskStore", () => { describe("file persistence", () => { it("persists tasks across store instances", async () => { const task = await store.create("Persistent task", { - description: "With description", + body: "With body", assignee: "claude", }); await store.addNote(task.id, "A note"); @@ -667,11 +681,152 @@ describe("FileTaskStore", () => { expect(retrieved).not.toBeNull(); expect(retrieved?.title).toBe("Persistent task"); - expect(retrieved?.description).toBe("With description"); + expect(retrieved?.body).toBe("With body"); expect(retrieved?.assignee).toBe("claude"); expect(retrieved?.created).toBe(task.created); expect(retrieved?.notes).toHaveLength(1); expect(retrieved?.notes[0].content).toBe("A note"); }); + + it("persists parentId across store instances", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(child.id); + + expect(retrieved?.parentId).toBe(parent.id); + }); + }); + + describe("parent-child hierarchy", () => { + describe("getAncestors", () => { + it("returns empty array for task with no parent", async () => { + const task = await store.create("Root task"); + + const ancestors = await store.getAncestors(task.id); + + expect(ancestors).toEqual([]); + }); + + it("returns parent for child task", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + + const ancestors = await store.getAncestors(child.id); + + expect(ancestors).toHaveLength(1); + expect(ancestors[0].id).toBe(parent.id); + }); + + it("returns full ancestor chain in order", async () => { + const grandparent = await store.create("Grandparent"); + const parent = await store.create("Parent", { + parentId: grandparent.id, + }); + const child = await store.create("Child", { parentId: parent.id }); + + const ancestors = await store.getAncestors(child.id); + + expect(ancestors).toHaveLength(2); + expect(ancestors[0].id).toBe(parent.id); + expect(ancestors[1].id).toBe(grandparent.id); + }); + + it("throws for non-existent task", async () => { + await expect(store.getAncestors("nonexistent")).rejects.toThrow(); + }); + }); + + describe("getChildren", () => { + it("returns empty array for task with no children", async () => { + const task = await store.create("Leaf task"); + + const children = await store.getChildren(task.id); + + expect(children).toEqual([]); + }); + + it("returns direct children", async () => { + const parent = await store.create("Parent"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + const child2 = await store.create("Child 2", { parentId: parent.id }); + + const children = await store.getChildren(parent.id); + + expect(children).toHaveLength(2); + expect(children.map((c) => c.id).sort()).toEqual( + [child1.id, child2.id].sort() + ); + }); + + it("does not return grandchildren", async () => { + const grandparent = await store.create("Grandparent"); + const parent = await store.create("Parent", { + parentId: grandparent.id, + }); + await store.create("Grandchild", { parentId: parent.id }); + + const children = await store.getChildren(grandparent.id); + + expect(children).toHaveLength(1); + expect(children[0].id).toBe(parent.id); + }); + }); + + describe("setParent", () => { + it("sets parent on a task", async () => { + const parent = await store.create("Parent"); + const task = await store.create("Task"); + + await store.setParent(task.id, parent.id); + + const updated = await store.get(task.id); + expect(updated?.parentId).toBe(parent.id); + }); + + it("removes parent when set to null", async () => { + const parent = await store.create("Parent"); + const task = await store.create("Task", { parentId: parent.id }); + + await store.setParent(task.id, null); + + const updated = await store.get(task.id); + expect(updated?.parentId).toBeUndefined(); + }); + + it("throws for non-existent task", async () => { + const parent = await store.create("Parent"); + + await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow( + "Task not found" + ); + }); + + it("throws for non-existent parent", async () => { + const task = await store.create("Task"); + + await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow( + "Parent task not found" + ); + }); + + it("throws when setting task as its own parent", async () => { + const task = await store.create("Task"); + + await expect(store.setParent(task.id, task.id)).rejects.toThrow( + "Task cannot be its own parent" + ); + }); + + it("throws when creating circular reference", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + + await expect(store.setParent(parent.id, child.id)).rejects.toThrow( + "circular reference" + ); + }); + }); }); }); diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index 7936574b3..caa7822bc 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -23,6 +23,10 @@ function serializeTask(task: Task): string { `created: ${task.created}`, ]; + if (task.parentId) { + frontmatterLines.push(`parentId: ${task.parentId}`); + } + if (task.assignee) { frontmatterLines.push(`assignee: ${task.assignee}`); } @@ -31,19 +35,19 @@ function serializeTask(task: Task): string { const frontmatter = frontmatterLines.join("\n"); - let body = ""; - if (task.description) { - body += task.description + "\n"; + let content = ""; + if (task.body) { + content += task.body + "\n"; } if (task.notes.length > 0) { - body += "\n## Notes\n"; + content += "\n## Notes\n"; for (const note of task.notes) { - body += `\n**${note.timestamp}**\n\n${note.content}\n`; + content += `\n**${note.timestamp}**\n\n${note.content}\n`; } } - return frontmatter + "\n\n" + body; + return frontmatter + "\n\n" + content; } function parseTask(content: string): Task { @@ -53,7 +57,7 @@ function parseTask(content: string): Task { } const frontmatter = frontmatterMatch[1]; - const body = content.slice(frontmatterMatch[0].length); + const fileBody = content.slice(frontmatterMatch[0].length); const getValue = (key: string): string => { const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m")); @@ -72,7 +76,7 @@ function parseTask(content: string): Task { // Parse notes from body const notes: Task["notes"] = []; - const notesSection = body.match(/## Notes\n([\s\S]*?)$/); + const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/); if (notesSection) { const noteMatches = notesSection[1].matchAll( /\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g @@ -85,21 +89,23 @@ function parseTask(content: string): Task { } } - // Description is everything before ## Notes - let description = body; + // Body is everything before ## Notes (backwards compat: also check for old "description" field name) + let taskBody = fileBody; if (notesSection) { - description = body.slice(0, body.indexOf("## Notes")).trim(); + taskBody = fileBody.slice(0, fileBody.indexOf("## Notes")).trim(); } - description = description.trim(); + taskBody = taskBody.trim(); const assignee = getValue("assignee") as AgentType | ""; + const parentId = getValue("parentId"); return { id: getValue("id"), title: getValue("title"), status: getValue("status") as TaskStatus, deps, - description, + parentId: parentId || undefined, + body: taskBody, notes, created: getValue("created") || new Date().toISOString(), assignee: assignee || undefined, @@ -148,7 +154,8 @@ export class FileTaskStore implements TaskStore { } } } - return tasks; + // Sort by created date (oldest first) for consistent ordering + return tasks.sort((a, b) => a.created.localeCompare(b.created)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; @@ -192,6 +199,32 @@ export class FileTaskStore implements TaskStore { return result; } + async getAncestors(id: string): Promise<Task[]> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + const ancestors: Task[] = []; + let currentId = task.parentId; + + while (currentId) { + const parent = await this.get(currentId); + if (!parent) break; + ancestors.push(parent); + currentId = parent.parentId; + } + + return ancestors; + } + + async getChildren(id: string): Promise<Task[]> { + const allTasks = await this.list(); + return allTasks + .filter((t) => t.parentId === id) + .sort((a, b) => a.created.localeCompare(b.created)); + } + async getReady(scopeId?: string): Promise<Task[]> { const allTasks = await this.list(); const taskMap = new Map(allTasks.map((t) => [t.id, t])); @@ -251,19 +284,28 @@ export class FileTaskStore implements TaskStore { candidates = await this.list(); } - // Sort by created date (most recent first) for closed tasks + // Sort by created date (oldest first) for consistent ordering return candidates .filter((t) => t.status === "done") - .sort((a, b) => b.created.localeCompare(a.created)); + .sort((a, b) => a.created.localeCompare(b.created)); } async create(title: string, opts?: CreateTaskOptions): Promise<Task> { + // Validate parent exists if provided + if (opts?.parentId) { + const parent = await this.get(opts.parentId); + if (!parent) { + throw new Error(`Parent task not found: ${opts.parentId}`); + } + } + const task: Task = { id: generateId(), title, status: opts?.status ?? "open", deps: opts?.deps ?? [], - description: opts?.description ?? "", + parentId: opts?.parentId, + body: opts?.body ?? "", notes: [], created: new Date().toISOString(), assignee: opts?.assignee, @@ -314,6 +356,45 @@ export class FileTaskStore implements TaskStore { await this.writeTask(task); } + async setParent(id: string, parentId: string | null): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + if (parentId) { + const parent = await this.get(parentId); + if (!parent) { + throw new Error(`Parent task not found: ${parentId}`); + } + // Prevent circular reference + if (parentId === id) { + throw new Error("Task cannot be its own parent"); + } + // Check that the new parent isn't a descendant of this task + const ancestors = await this.getAncestorsFrom(parentId); + if (ancestors.some((a) => a.id === id)) { + throw new Error("Cannot set parent: would create circular reference"); + } + } + + await this.update(id, { parentId: parentId ?? undefined }); + } + + private async getAncestorsFrom(id: string): Promise<Task[]> { + const ancestors: Task[] = []; + let currentId: string | undefined = id; + + while (currentId) { + const task = await this.get(currentId); + if (!task) break; + ancestors.push(task); + currentId = task.parentId; + } + + return ancestors; + } + async addNote(id: string, content: string): Promise<void> { const task = await this.get(id); if (!task) { diff --git a/packages/server/src/tasks/types.ts b/packages/server/src/tasks/types.ts index c740f3516..d4a15dd72 100644 --- a/packages/server/src/tasks/types.ts +++ b/packages/server/src/tasks/types.ts @@ -11,8 +11,9 @@ export interface Task { id: string; // random hash, e.g. "a1b2c3d4" title: string; status: TaskStatus; - deps: string[]; - description: string; // long form markdown + deps: string[]; // task IDs this task depends on (must be done before this can start) + parentId?: string; // parent task ID for hierarchical structure (context inheritance) + body: string; // long form markdown document notes: Note[]; created: string; // ISO date assignee?: AgentType; // optional agent override @@ -20,8 +21,9 @@ export interface Task { export interface CreateTaskOptions { deps?: string[]; + parentId?: string; status?: TaskStatus; - description?: string; + body?: string; assignee?: AgentType; } @@ -30,6 +32,8 @@ export interface TaskStore { list(): Promise<Task[]>; get(id: string): Promise<Task | null>; getDepTree(id: string): Promise<Task[]>; // all descendants in dep graph + getAncestors(id: string): Promise<Task[]>; // parent chain from immediate parent to root + getChildren(id: string): Promise<Task[]>; // direct children of a task getReady(scopeId?: string): Promise<Task[]>; // open + all deps done, optionally scoped getBlocked(scopeId?: string): Promise<Task[]>; // open/in_progress but has unresolved deps getClosed(scopeId?: string): Promise<Task[]>; // done tasks, optionally scoped @@ -39,6 +43,7 @@ export interface TaskStore { update(id: string, changes: Partial<Omit<Task, "id" | "created">>): Promise<Task>; addDep(id: string, depId: string): Promise<void>; removeDep(id: string, depId: string): Promise<void>; + setParent(id: string, parentId: string | null): Promise<void>; addNote(id: string, content: string): Promise<void>; // Status transitions