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 ", "Task description")
+ .option("-b, --body ", "Task body (use '-' to read from stdin)")
.option("--deps ", "Comma-separated dependency IDs")
- .option("--parent ", "Parent task (parent will depend on this new task)")
+ .option("--parent ", "Parent task ID (for hierarchy)")
.option("--assignee ", "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 ", "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 ")
- .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 ")
- .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();
- 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 ")
+ .description("Update task properties")
+ .option("-t, --title ", "New title")
+ .option("-b, --body ", "New body (use '-' to read from stdin)")
+ .option("--assignee ", "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 = {};
+
+ 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 ")
+ .description("Move task to a different parent")
+ .option("--parent ", "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 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 ")
+ .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 ")
.description("Add a timestamped note")
@@ -251,38 +417,52 @@ async function makePrompt(
): Promise {
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 \` 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 \` - see dependency graph from any task
-- \`task show \` - view task details and notes
+- \`task show \` - view task details with parent context
+- \`task children \` - list subtasks
+- \`task update --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 {
+ 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 {
+ 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 {
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 {
+ // 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 {
+ 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 {
+ 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 {
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;
get(id: string): Promise;
getDepTree(id: string): Promise; // all descendants in dep graph
+ getAncestors(id: string): Promise; // parent chain from immediate parent to root
+ getChildren(id: string): Promise; // direct children of a task
getReady(scopeId?: string): Promise; // open + all deps done, optionally scoped
getBlocked(scopeId?: string): Promise; // open/in_progress but has unresolved deps
getClosed(scopeId?: string): Promise; // done tasks, optionally scoped
@@ -39,6 +43,7 @@ export interface TaskStore {
update(id: string, changes: Partial>): Promise;
addDep(id: string, depId: string): Promise;
removeDep(id: string, depId: string): Promise;
+ setParent(id: string, parentId: string | null): Promise;
addNote(id: string, content: string): Promise;
// Status transitions