feat: add worktree archiving with context menu and improved logging
This commit is contained in:
@@ -98,6 +98,7 @@ function AgentListRow({
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const archivedLabel = agent.archivedAt ? "Archived" : null;
|
||||
const checkoutQuery = useCheckoutStatusCacheOnly({
|
||||
serverId: agent.serverId,
|
||||
cwd: agent.cwd,
|
||||
@@ -137,7 +138,8 @@ function AgentListRow({
|
||||
|
||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
||||
{shortenPath(projectPath)}
|
||||
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
|
||||
{branchLabel ? ` · ${branchLabel}` : ""}
|
||||
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Pressable, Image, Platform } from 'react-native'
|
||||
import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
useCallback,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'react'
|
||||
import { router, usePathname, useSegments } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
||||
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
@@ -27,8 +27,17 @@ import {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-agents-list'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from '@/components/ui/context-menu'
|
||||
|
||||
type SidebarTreeRow =
|
||||
| {
|
||||
@@ -76,7 +85,7 @@ interface WorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
drag: () => void
|
||||
}
|
||||
|
||||
function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string {
|
||||
@@ -135,10 +144,25 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
|
||||
: theme.colors.border
|
||||
}
|
||||
|
||||
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
loading = false,
|
||||
}: {
|
||||
bucket: SidebarWorkspaceEntry['statusBucket']
|
||||
loading?: boolean
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
const color = resolveStatusDotColor({ theme, bucket })
|
||||
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
@@ -201,10 +225,71 @@ function ProjectRow({
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
const didLongPressRef = useRef(false)
|
||||
function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
|
||||
const { setAnchorRect, setOpen } = useContextMenu()
|
||||
const didLongPressRef = useRef(false)
|
||||
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const longPressArmedRef = useRef(false)
|
||||
const longPressCancelledRef = useRef(false)
|
||||
const didStartDragRef = useRef(false)
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.cwd,
|
||||
actionId: 'archive-worktree',
|
||||
})
|
||||
)
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
const isArchiving = archiveStatus === 'pending'
|
||||
|
||||
const openContextMenuAtTouchStart = useCallback(() => {
|
||||
const point = touchStartRef.current
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
|
||||
setAnchorRect({
|
||||
x: point.x,
|
||||
y: point.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
setOpen(true)
|
||||
}, [setAnchorRect, setOpen])
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (!workspace.isPaseoOwnedWorktree) {
|
||||
return
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: 'Archive worktree?',
|
||||
message: `Archive this worktree?\n\n${workspace.cwd}`,
|
||||
confirmLabel: 'Archive',
|
||||
cancelLabel: 'Cancel',
|
||||
destructive: true,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
return runArchiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.cwd,
|
||||
worktreePath: workspace.cwd,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to archive worktree'
|
||||
Alert.alert('Error', message)
|
||||
})
|
||||
}, [runArchiveWorktree, workspace.cwd, workspace.isPaseoOwnedWorktree, workspace.serverId])
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (didLongPressRef.current) {
|
||||
didLongPressRef.current = false
|
||||
@@ -214,25 +299,115 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
|
||||
}, [onPress])
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (longPressCancelledRef.current) {
|
||||
return
|
||||
}
|
||||
didLongPressRef.current = true
|
||||
onLongPress()
|
||||
}, [onLongPress])
|
||||
longPressArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
const moveMonitorGesture = useMemo(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return null
|
||||
}
|
||||
|
||||
const CANCEL_SLOP_PX = 10
|
||||
const DRAG_SLOP_PX = 8
|
||||
|
||||
return Gesture.Pan()
|
||||
.manualActivation(true)
|
||||
.runOnJS(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0]
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail()
|
||||
return
|
||||
}
|
||||
|
||||
const start = touchStartRef.current
|
||||
if (!start) {
|
||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
||||
return
|
||||
}
|
||||
|
||||
const dx = touch.absoluteX - start.x
|
||||
const dy = touch.absoluteY - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (!longPressArmedRef.current) {
|
||||
if (distance > CANCEL_SLOP_PX) {
|
||||
longPressCancelledRef.current = true
|
||||
stateManager.fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (didStartDragRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (distance > DRAG_SLOP_PX) {
|
||||
didStartDragRef.current = true
|
||||
drag()
|
||||
stateManager.fail()
|
||||
}
|
||||
})
|
||||
}, [drag])
|
||||
|
||||
const trigger = (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
selected && styles.workspaceRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={(event) => {
|
||||
if (didLongPressCleanupTimerRef.current) {
|
||||
clearTimeout(didLongPressCleanupTimerRef.current)
|
||||
didLongPressCleanupTimerRef.current = null
|
||||
}
|
||||
longPressCancelledRef.current = false
|
||||
longPressArmedRef.current = false
|
||||
didStartDragRef.current = false
|
||||
touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return
|
||||
}
|
||||
if (!longPressArmedRef.current || didStartDragRef.current) {
|
||||
longPressCancelledRef.current = false
|
||||
longPressArmedRef.current = false
|
||||
didStartDragRef.current = false
|
||||
touchStartRef.current = null
|
||||
return
|
||||
}
|
||||
openContextMenuAtTouchStart()
|
||||
didLongPressCleanupTimerRef.current = setTimeout(() => {
|
||||
didLongPressRef.current = false
|
||||
didLongPressCleanupTimerRef.current = null
|
||||
}, 0)
|
||||
longPressCancelledRef.current = false
|
||||
longPressArmedRef.current = false
|
||||
didStartDragRef.current = false
|
||||
touchStartRef.current = null
|
||||
}}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={200}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowLeft}>
|
||||
<WorkspaceStatusDot bucket={workspace.statusBucket} />
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{resolveWorkspaceBranchLabel(workspace)}
|
||||
</Text>
|
||||
@@ -242,7 +417,54 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
|
||||
{createdAtLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{moveMonitorGesture ? (
|
||||
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
|
||||
) : (
|
||||
trigger
|
||||
)}
|
||||
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
leading={<Archive size={16} color={theme.colors.foregroundMuted} />}
|
||||
destructive
|
||||
disabled={!workspace.isPaseoOwnedWorktree}
|
||||
status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'}
|
||||
pendingLabel="Archiving…"
|
||||
successLabel="Archived"
|
||||
onSelect={handleArchiveWorktree}
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
>
|
||||
Archive worktree
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
drag,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<WorkspaceRow workspace={workspace} selected={selected} onPress={onPress} drag={drag} />
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -452,7 +674,7 @@ export function SidebarAgentList({
|
||||
activeWorkspaceSelection.workspaceId === item.workspace.cwd
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={item.workspace}
|
||||
selected={isSelected}
|
||||
onPress={() => {
|
||||
@@ -462,7 +684,7 @@ export function SidebarAgentList({
|
||||
onWorkspacePress?.()
|
||||
navigate(workspaceRoute as any)
|
||||
}}
|
||||
onLongPress={drag}
|
||||
drag={drag}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -671,6 +893,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
workspaceStatusDotFill: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -58,6 +58,10 @@ function useContextMenuContext(componentName: string): ContextMenuContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useContextMenu(): ContextMenuContextValue {
|
||||
return useContextMenuContext("useContextMenu");
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
open,
|
||||
defaultOpen,
|
||||
|
||||
@@ -55,6 +55,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
}
|
||||
const serverLabel = serverLabelById.get(serverId) ?? serverId;
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const nextAgent: AggregatedAgent = {
|
||||
id: agent.id,
|
||||
serverId,
|
||||
|
||||
@@ -74,9 +74,6 @@ export function useAllAgentsList(options?: {
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
if (aggregated.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
if (aggregated.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,7 @@ export class HostRuntimeStore {
|
||||
controller.markAgentDirectorySyncLoading();
|
||||
try {
|
||||
const payload = await client.fetchAgents({
|
||||
filter: input.filter ?? { labels: { ui: "true" } },
|
||||
filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true },
|
||||
...(input.subscribe ? { subscribe: input.subscribe } : {}),
|
||||
...(input.page ? { page: input.page } : {}),
|
||||
});
|
||||
|
||||
@@ -86,6 +86,49 @@ function invalidateWorktreeList() {
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorktreeFromCachedLists(input: {
|
||||
serverId: string;
|
||||
worktreePath: string;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeFromList = (current: unknown) => {
|
||||
if (!Array.isArray(current)) {
|
||||
return current;
|
||||
}
|
||||
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
||||
return filtered.length === current.length ? current : filtered;
|
||||
};
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "paseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList
|
||||
);
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList
|
||||
);
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -267,6 +310,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
invalidateWorktreeList();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -801,6 +801,15 @@ export class AgentManager {
|
||||
|
||||
async closeAgent(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
pendingPermissions: agent.pendingPermissions.size,
|
||||
},
|
||||
"closeAgent: start"
|
||||
);
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
this.liveEventBacklog.delete(agentId);
|
||||
@@ -816,6 +825,7 @@ export class AgentManager {
|
||||
};
|
||||
await session.close();
|
||||
this.emitState(closedAgent);
|
||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
@@ -1005,7 +1015,24 @@ export class AgentManager {
|
||||
options?: AgentRunOptions
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const existingAgent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
hasPendingRun: Boolean(existingAgent.pendingRun),
|
||||
promptType: typeof prompt === "string" ? "string" : "structured",
|
||||
hasRunOptions: Boolean(options),
|
||||
},
|
||||
"streamAgent: requested"
|
||||
);
|
||||
if (existingAgent.pendingRun) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
lifecycle: existingAgent.lifecycle,
|
||||
},
|
||||
"streamAgent: rejected because pendingRun already exists"
|
||||
);
|
||||
throw new Error(`Agent ${agentId} already has an active run`);
|
||||
}
|
||||
|
||||
@@ -1015,12 +1042,31 @@ export class AgentManager {
|
||||
|
||||
let finalized = false;
|
||||
const finalize = (error?: string) => {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
error,
|
||||
alreadyFinalized: finalized,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent.finalize: invoked"
|
||||
);
|
||||
if (finalized) {
|
||||
return;
|
||||
}
|
||||
finalized = true;
|
||||
|
||||
if (agent.pendingRun !== streamForwarder) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
error,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder"
|
||||
);
|
||||
if (error) {
|
||||
agent.lastError = error;
|
||||
}
|
||||
@@ -1043,6 +1089,15 @@ export class AgentManager {
|
||||
mutableAgent.cwd
|
||||
);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
lifecycle: mutableAgent.lifecycle,
|
||||
hasPendingRun: Boolean(mutableAgent.pendingRun),
|
||||
terminalError,
|
||||
},
|
||||
"streamAgent.finalize: applying terminal state"
|
||||
);
|
||||
this.emitState(mutableAgent);
|
||||
this.flushLiveEventBacklog(mutableAgent);
|
||||
};
|
||||
@@ -1054,6 +1109,22 @@ export class AgentManager {
|
||||
try {
|
||||
for await (const event of iterator) {
|
||||
self.handleStreamEvent(agent, event);
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
self.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
eventType: event.type,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent: forwarded terminal/turn event"
|
||||
);
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1079,6 +1150,14 @@ export class AgentManager {
|
||||
// deterministically order idle->running transitions.
|
||||
this.touchUpdatedAt(agent);
|
||||
self.emitState(agent);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"streamAgent: started"
|
||||
);
|
||||
|
||||
return streamForwarder;
|
||||
}
|
||||
@@ -1743,6 +1822,14 @@ export class AgentManager {
|
||||
}
|
||||
break;
|
||||
case "turn_completed":
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_completed"
|
||||
);
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
if (!agent.pendingRun && agent.lifecycle !== "idle") {
|
||||
@@ -1774,6 +1861,14 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_canceled":
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_canceled"
|
||||
);
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "idle";
|
||||
}
|
||||
@@ -1792,6 +1887,14 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "turn_started":
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: agent.id,
|
||||
lifecycle: agent.lifecycle,
|
||||
hasPendingRun: Boolean(agent.pendingRun),
|
||||
},
|
||||
"handleStreamEvent: turn_started"
|
||||
);
|
||||
if (!agent.pendingRun) {
|
||||
(agent as ActiveManagedAgent).lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
@@ -2143,6 +2246,14 @@ export class AgentManager {
|
||||
// Keep consuming provider events even during an active foreground run,
|
||||
// then replay them immediately once that run settles.
|
||||
if (latest.pendingRun) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: latest.id,
|
||||
eventType: event.type,
|
||||
backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1,
|
||||
},
|
||||
"Live event pump: queued event because pendingRun is active"
|
||||
);
|
||||
this.enqueueLiveEvent(latest.id, event);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1906,6 +1906,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
turnState: this.turnState,
|
||||
hasQuery: Boolean(this.query),
|
||||
hasInput: Boolean(this.input),
|
||||
hasActiveForegroundTurn: Boolean(this.activeForegroundTurn),
|
||||
},
|
||||
"Claude session close: start"
|
||||
);
|
||||
this.closed = true;
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
this.cancelCurrentTurn?.();
|
||||
@@ -1922,6 +1932,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.logger.trace(
|
||||
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
||||
"Claude session close: completed"
|
||||
);
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
@@ -2235,8 +2249,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
label: string
|
||||
): Promise<void> {
|
||||
if (!promise) {
|
||||
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
this.logger.trace({ label }, "Claude query operation wait start");
|
||||
try {
|
||||
await Promise.race([
|
||||
promise,
|
||||
@@ -2244,6 +2261,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
setTimeout(() => reject(new Error("timeout")), 3_000);
|
||||
}),
|
||||
]);
|
||||
this.logger.trace(
|
||||
{ label, durationMs: Date.now() - startedAt },
|
||||
"Claude query operation settled"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
|
||||
}
|
||||
@@ -2464,6 +2485,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emitRunEvent(run: RunRecord, event: AgentStreamEvent): void {
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
this.logger.trace(
|
||||
{
|
||||
runId: run.id,
|
||||
owner: run.owner,
|
||||
runState: run.state,
|
||||
eventType: event.type,
|
||||
routedTo: run.owner === "foreground" && run.queue ? "foreground_queue" : "live_queue",
|
||||
},
|
||||
"Claude run event emitted"
|
||||
);
|
||||
}
|
||||
if (run.owner === "foreground" && run.queue) {
|
||||
run.queue.push(event);
|
||||
if (
|
||||
@@ -2494,6 +2532,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurn = null;
|
||||
this.preReplayMetadataSeen = false;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
runId: run.id,
|
||||
owner: run.owner,
|
||||
eventType: event.type,
|
||||
runState: run.state,
|
||||
hasActiveForegroundTurn: Boolean(this.activeForegroundTurn),
|
||||
},
|
||||
"Claude run terminal event handled"
|
||||
);
|
||||
this.transitionTurnStateFromActiveRuns(`run ${run.id} terminal`);
|
||||
}
|
||||
|
||||
@@ -2789,6 +2837,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (next.done) {
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
activeRunCount: this.runTracker.listActiveRuns().length,
|
||||
},
|
||||
"Claude query pump next() returned done"
|
||||
);
|
||||
this.input?.end();
|
||||
await this.awaitWithTimeout(q.return?.(), "query pump return on done");
|
||||
if (this.query === q) {
|
||||
@@ -2834,6 +2889,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
run: route.run,
|
||||
message,
|
||||
});
|
||||
this.logger.trace(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
messageType: message.type,
|
||||
routeReason: route.reason,
|
||||
runId: route.run?.id ?? null,
|
||||
runOwner: route.run?.owner ?? null,
|
||||
suppressTerminalEvents,
|
||||
metadataOnly,
|
||||
},
|
||||
"Claude query pump routed SDK message"
|
||||
);
|
||||
if (route.run) {
|
||||
this.transitionTurnStateFromActiveRuns(`routed via ${route.reason}`);
|
||||
this.runTracker.bindIdentifiers(route.run, identifiers);
|
||||
@@ -2898,6 +2965,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// Pre-replay success results are stale in practice (leftover from an
|
||||
// earlier query segment) and must not end the current foreground run.
|
||||
if (resultSubtype === "success") {
|
||||
this.logger.trace(
|
||||
{
|
||||
runId: run.id,
|
||||
runOwner: run.owner,
|
||||
runState: run.state,
|
||||
promptReplaySeen: run.promptReplaySeen,
|
||||
resultSubtype,
|
||||
},
|
||||
"Suppressing pre-replay foreground success result terminal event"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2917,6 +2994,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
message: SDKMessage,
|
||||
identifiers: EventIdentifiers
|
||||
): void {
|
||||
const previousState = run.state;
|
||||
if (
|
||||
message.type === "user" &&
|
||||
identifiers.messageId &&
|
||||
@@ -2941,8 +3019,25 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (message.type === "result") {
|
||||
this.runTracker.transition(run, "finalizing");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.state !== previousState) {
|
||||
this.logger.trace(
|
||||
{
|
||||
runId: run.id,
|
||||
owner: run.owner,
|
||||
messageType: message.type,
|
||||
previousState,
|
||||
nextState: run.state,
|
||||
taskId: identifiers.taskId,
|
||||
parentMessageId: identifiers.parentMessageId,
|
||||
messageId: identifiers.messageId,
|
||||
},
|
||||
"Updated Claude run lifecycle from SDK message"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean {
|
||||
|
||||
@@ -736,10 +736,15 @@ export class Session {
|
||||
private async interruptAgentIfRunning(agentId: string): Promise<void> {
|
||||
const snapshot = this.agentManager.getAgent(agentId)
|
||||
if (!snapshot) {
|
||||
this.sessionLogger.trace({ agentId }, 'interruptAgentIfRunning: agent not found')
|
||||
throw new Error(`Agent ${agentId} not found`)
|
||||
}
|
||||
|
||||
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
|
||||
this.sessionLogger.trace(
|
||||
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
|
||||
'interruptAgentIfRunning: skipping because agent is not running'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,9 +792,18 @@ export class Session {
|
||||
prompt: AgentPromptInput,
|
||||
runOptions?: AgentRunOptions
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
this.sessionLogger.trace(
|
||||
{
|
||||
agentId,
|
||||
promptType: typeof prompt === 'string' ? 'string' : 'structured',
|
||||
hasRunOptions: Boolean(runOptions),
|
||||
},
|
||||
'startAgentStream: requested'
|
||||
)
|
||||
let iterator: AsyncGenerator<AgentStreamEvent>
|
||||
try {
|
||||
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
|
||||
this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator')
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(agentId, error, 'Failed to start agent run')
|
||||
const message =
|
||||
@@ -802,7 +816,9 @@ export class Session {
|
||||
for await (const _ of iterator) {
|
||||
// Events are forwarded via the session's AgentManager subscription.
|
||||
}
|
||||
this.sessionLogger.trace({ agentId }, 'startAgentStream: iterator drained')
|
||||
} catch (error) {
|
||||
this.sessionLogger.trace({ agentId, err: error }, 'startAgentStream: iterator threw')
|
||||
this.handleAgentRunError(agentId, error, 'Agent stream failed')
|
||||
}
|
||||
})()
|
||||
|
||||
+6
-3
@@ -10,8 +10,11 @@ DAEMON_PORT=$(get-port 6767 6768 6769 6770 6771 6772 6773)
|
||||
METRO_PORT=$(get-port 8081 8082 8083 8084 8085 8086 8087)
|
||||
|
||||
# Use a temporary PASEO_HOME to avoid conflicts between dev instances
|
||||
# export PASEO_HOME=$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")
|
||||
# trap "rm -rf '$PASEO_HOME'" EXIT
|
||||
if [ -z "${PASEO_HOME}" ]; then
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")"
|
||||
trap "rm -rf '$PASEO_HOME'" EXIT
|
||||
fi
|
||||
|
||||
# Build CORS origins for this Expo instance
|
||||
CORS_ORIGINS="http://localhost:${METRO_PORT},http://127.0.0.1:${METRO_PORT}"
|
||||
@@ -24,7 +27,7 @@ echo " Paseo Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Daemon: http://localhost:${DAEMON_PORT}"
|
||||
echo " Metro: http://localhost:${METRO_PORT}"
|
||||
# echo " Home: ${PASEO_HOME}"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Export for child processes (overrides .env values)
|
||||
|
||||
Reference in New Issue
Block a user