feat(app): add maestro tests, download progress, and task hierarchy
Add Maestro E2E test flows for app launch and sidebar interactions. Implement download progress tracking with speed/ETA display in file explorer. Extend task CLI with parent-child hierarchy, body content from stdin, and ancestor context in task show. Fix explorer sidebar gesture to allow horizontal scrolling while preserving close swipe. Replace hardcoded monospace font with Fonts.mono constant. Fix RefreshControl to only show spinner on manual pull-to-refresh.
BIN
packages/app/01-diff-sidebar-open.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/02-after-vertical-scroll.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
packages/app/02-tabs-work.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
packages/app/03-after-horizontal-scroll-right.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
packages/app/03-after-vertical-scroll.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/04-after-horizontal-scroll-right.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/04-back-at-left-edge.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
packages/app/05-back-at-left-edge.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/app-launched.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
packages/app/current-state.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/app/dev-client.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/app/diff-sidebar-open.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
packages/app/fail3.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
packages/app/launch-screen.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
packages/app/maestro-fail.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
packages/app/maestro-fail2.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
26
packages/app/maestro/flows/launch.yaml
Normal file
@@ -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"
|
||||
11
packages/app/maestro/poc.yaml
Normal file
@@ -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"
|
||||
74
packages/app/maestro/sidebar-swipe-bug.yaml
Normal file
@@ -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
|
||||
BIN
packages/app/manual-launch.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
packages/app/settings-screen.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
packages/app/sidebar-fail.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
packages/app/sidebar-fail2.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -258,16 +258,13 @@ export default function RootLayout() {
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
fullScreenGestureEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
|
||||
@@ -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() {
|
||||
<BackHeader title="All Agents" />
|
||||
<AgentList
|
||||
agents={sortedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number>(0);
|
||||
const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null);
|
||||
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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({
|
||||
</Text>
|
||||
<Text style={styles.downloadToastStatus}>
|
||||
{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"}
|
||||
</Text>
|
||||
{downloadToast.status === "downloading" && downloadToast.progress && (
|
||||
<View style={styles.downloadProgressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.downloadProgressFill,
|
||||
{ width: `${Math.round(downloadToast.progress.percent * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{downloadToast.status !== "downloading" && (
|
||||
<Pressable
|
||||
@@ -983,6 +1044,28 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
}
|
||||
if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||
}
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 1) {
|
||||
return "< 1s";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function formatModifiedTime({ value }: { value: string }): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -1241,7 +1324,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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],
|
||||
},
|
||||
|
||||
@@ -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<HighlightToken["style"]>;
|
||||
|
||||
@@ -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<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | 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<NativeScrollEvent>) => {
|
||||
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
|
||||
</Pressable>
|
||||
{isExpanded && (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
@@ -192,6 +209,11 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e) => 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}
|
||||
>
|
||||
<View style={[styles.linesContainer, scrollViewWidth > 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={
|
||||
<RefreshControl
|
||||
refreshing={isFetching && !isLoading}
|
||||
onRefresh={refresh}
|
||||
refreshing={isManualRefresh && isFetching}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={theme.colors.mutedForeground}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 */}
|
||||
<AgentList
|
||||
agents={limitedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
listFooterComponent={viewMoreButton}
|
||||
@@ -246,8 +261,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
{/* Middle: scrollable agent list */}
|
||||
<AgentList
|
||||
agents={limitedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
listFooterComponent={viewMoreButton}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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<string> {
|
||||
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 <id> --body ..."
|
||||
- While implementing: add notes with "task note <id> ..."
|
||||
- When done: add a final note explaining what was done, then close
|
||||
`
|
||||
);
|
||||
|
||||
program
|
||||
.command("create <title>")
|
||||
.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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||