/ state / teammateViewHelpers.ts
teammateViewHelpers.ts
  1  import { logEvent } from '../services/analytics/index.js'
  2  import { isTerminalTaskStatus } from '../Task.js'
  3  import type { LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'
  4  
  5  // Inlined from framework.ts — importing creates a cycle through
  6  // BackgroundTasksDialog. Keep in sync with PANEL_GRACE_MS there.
  7  const PANEL_GRACE_MS = 30_000
  8  
  9  import type { AppState } from './AppState.js'
 10  
 11  // Inline type check instead of importing isLocalAgentTask — breaks the
 12  // teammateViewHelpers → LocalAgentTask runtime edge that creates a cycle
 13  // through BackgroundTasksDialog.
 14  function isLocalAgent(task: unknown): task is LocalAgentTaskState {
 15    return (
 16      typeof task === 'object' &&
 17      task !== null &&
 18      'type' in task &&
 19      task.type === 'local_agent'
 20    )
 21  }
 22  
 23  /**
 24   * Return the task released back to stub form: retain dropped, messages
 25   * cleared, evictAfter set if terminal. Shared by exitTeammateView and
 26   * the switch-away path in enterTeammateView.
 27   */
 28  function release(task: LocalAgentTaskState): LocalAgentTaskState {
 29    return {
 30      ...task,
 31      retain: false,
 32      messages: undefined,
 33      diskLoaded: false,
 34      evictAfter: isTerminalTaskStatus(task.status)
 35        ? Date.now() + PANEL_GRACE_MS
 36        : undefined,
 37    }
 38  }
 39  
 40  /**
 41   * Transitions the UI to view a teammate's transcript.
 42   * Sets viewingAgentTaskId and, for local_agent, retain: true (blocks eviction,
 43   * enables stream-append, triggers disk bootstrap) and clears evictAfter.
 44   * If switching from another agent, releases the previous one back to stub.
 45   */
 46  export function enterTeammateView(
 47    taskId: string,
 48    setAppState: (updater: (prev: AppState) => AppState) => void,
 49  ): void {
 50    logEvent('tengu_transcript_view_enter', {})
 51    setAppState(prev => {
 52      const task = prev.tasks[taskId]
 53      const prevId = prev.viewingAgentTaskId
 54      const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined
 55      const switching =
 56        prevId !== undefined &&
 57        prevId !== taskId &&
 58        isLocalAgent(prevTask) &&
 59        prevTask.retain
 60      const needsRetain =
 61        isLocalAgent(task) && (!task.retain || task.evictAfter !== undefined)
 62      const needsView =
 63        prev.viewingAgentTaskId !== taskId ||
 64        prev.viewSelectionMode !== 'viewing-agent'
 65      if (!needsRetain && !needsView && !switching) return prev
 66      let tasks = prev.tasks
 67      if (switching || needsRetain) {
 68        tasks = { ...prev.tasks }
 69        if (switching) tasks[prevId] = release(prevTask)
 70        if (needsRetain) {
 71          tasks[taskId] = { ...task, retain: true, evictAfter: undefined }
 72        }
 73      }
 74      return {
 75        ...prev,
 76        viewingAgentTaskId: taskId,
 77        viewSelectionMode: 'viewing-agent',
 78        tasks,
 79      }
 80    })
 81  }
 82  
 83  /**
 84   * Exit teammate transcript view and return to leader's view.
 85   * Drops retain and clears messages back to stub form; if terminal,
 86   * schedules eviction via evictAfter so the row lingers briefly.
 87   */
 88  export function exitTeammateView(
 89    setAppState: (updater: (prev: AppState) => AppState) => void,
 90  ): void {
 91    logEvent('tengu_transcript_view_exit', {})
 92    setAppState(prev => {
 93      const id = prev.viewingAgentTaskId
 94      const cleared = {
 95        ...prev,
 96        viewingAgentTaskId: undefined,
 97        viewSelectionMode: 'none' as const,
 98      }
 99      if (id === undefined) {
100        return prev.viewSelectionMode === 'none' ? prev : cleared
101      }
102      const task = prev.tasks[id]
103      if (!isLocalAgent(task) || !task.retain) return cleared
104      return {
105        ...cleared,
106        tasks: { ...prev.tasks, [id]: release(task) },
107      }
108    })
109  }
110  
111  /**
112   * Context-sensitive x: running → abort, terminal → dismiss.
113   * Dismiss sets evictAfter=0 so the filter hides immediately.
114   * If viewing the dismissed agent, also exits to leader.
115   */
116  export function stopOrDismissAgent(
117    taskId: string,
118    setAppState: (updater: (prev: AppState) => AppState) => void,
119  ): void {
120    setAppState(prev => {
121      const task = prev.tasks[taskId]
122      if (!isLocalAgent(task)) return prev
123      if (task.status === 'running') {
124        task.abortController?.abort()
125        return prev
126      }
127      if (task.evictAfter === 0) return prev
128      const viewingThis = prev.viewingAgentTaskId === taskId
129      return {
130        ...prev,
131        tasks: {
132          ...prev.tasks,
133          [taskId]: { ...release(task), evictAfter: 0 },
134        },
135        ...(viewingThis && {
136          viewingAgentTaskId: undefined,
137          viewSelectionMode: 'none',
138        }),
139      }
140    })
141  }