• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / aiforge / 21570337701

01 Feb 2026 09:11PM UTC coverage: 81.026% (-2.9%) from 83.954%
21570337701

push

github

apowers313
test: increase coverage to 80%+

2049 of 2382 branches covered (86.02%)

Branch coverage included in aggregate %.

1849 of 2529 new or added lines in 25 files covered. (73.11%)

681 existing lines in 21 files now uncovered.

9861 of 12317 relevant lines covered (80.06%)

26.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.05
/src/client/components/shells/ShellItem.tsx
1
import { useState, useEffect } from 'react';
1✔
2
import { ActionIcon, Menu, Badge, Modal, TextInput, Button, Stack, Group } from '@mantine/core';
1✔
3
import { IconTerminal2, IconDots, IconTrash, IconPencil, IconRefresh, IconSparkles, IconCheck, IconPlayerPlay } from '@tabler/icons-react';
1✔
4
import type { Shell } from '@shared/types';
5
import { useDeleteShell, useUpdateShell, useRestartShell, useActiveShellId } from '@client/hooks/useShells';
1✔
6
import { useUIStore } from '@client/stores/uiStore';
1✔
7
import { TreeItem } from '@client/components/common/TreeItem';
1✔
8

9
interface ShellItemProps {
10
  shell: Shell;
11
  projectId: string;
12
  /** Timeout in milliseconds before AI shell is considered idle (default: 5000) */
13
  aiIdleTimeoutMs?: number;
14
  /** Tree depth for proper indentation (default: 1) */
15
  depth?: number;
16
}
17

18
/**
19
 * Check if a shell has recent activity (utility function)
20
 */
21
function isShellRecentlyActive(
30✔
22
  shell: Shell,
30✔
23
  clientActivityTimestamp: number | undefined,
30✔
24
  idleTimeoutMs: number,
30✔
25
): boolean {
30✔
26
  let lastActivityTime: number | null = null;
30✔
27

28
  if (clientActivityTimestamp) {
30✔
29
    lastActivityTime = clientActivityTimestamp;
10✔
30
  } else if (shell.lastActivityAt) {
30✔
31
    lastActivityTime = new Date(shell.lastActivityAt).getTime();
2✔
32
  }
2✔
33

34
  if (!lastActivityTime) {
30✔
35
    return false;
18✔
36
  }
18✔
37

38
  const now = Date.now();
12✔
39
  return now - lastActivityTime < idleTimeoutMs;
12✔
40
}
12✔
41

42
/**
43
 * Hook to determine if an AI shell has had recent activity (input or output)
44
 * Uses client-side tracking from UI store for immediate responsiveness
45
 */
46
export function useAiShellActivity(shell: Shell, idleTimeoutMs: number): boolean {
1✔
47
  const [isRecentlyActive, setIsRecentlyActive] = useState(false);
101✔
48
  const clientActivityTimestamp = useUIStore(
101✔
49
    (state) => state.shellActivityTimestamps[shell.id],
101✔
50
  );
101✔
51

52
  useEffect(() => {
101✔
53
    if (shell.type !== 'ai') {
35✔
54
      return;
22✔
55
    }
22✔
56

57
    const checkActivity = (): void => {
13✔
58
      const isActive = isShellRecentlyActive(shell, clientActivityTimestamp, idleTimeoutMs);
14✔
59
      const now = Date.now();
14✔
60
      const timeSinceClient = clientActivityTimestamp ? String(now - clientActivityTimestamp) : 'N/A';
14✔
61
      const timeSinceServer = shell.lastActivityAt ? String(now - new Date(shell.lastActivityAt).getTime()) : 'N/A';
14✔
62
      console.log(`[AI_INDICATOR] shell=${shell.name} isActive=${String(isActive)} clientTs=${timeSinceClient}ms serverTs=${timeSinceServer}ms`);
14✔
63
      setIsRecentlyActive(isActive);
14✔
64
    };
14✔
65

66
    // Check immediately
67
    checkActivity();
13✔
68

69
    // Set up interval to re-check periodically
70
    const interval = setInterval(checkActivity, 1000);
13✔
71

72
    return (): void => {
13✔
73
      clearInterval(interval);
13✔
74
    };
13✔
75
  }, [shell.type, shell.id, shell.lastActivityAt, clientActivityTimestamp, idleTimeoutMs]);
101✔
76

77
  return isRecentlyActive;
101✔
78
}
101✔
79

80
export type ProjectAiStatus = 'red' | 'green' | 'blue' | null;
81

82
/**
83
 * Worktree info needed for status calculation
84
 */
85
export interface WorktreeStatusInfo {
86
  path: string;
87
  done: boolean;
88
}
89

90
/**
91
 * Calculate status for a group of AI shells
92
 * Returns: 'red' | 'green' | 'blue' | null
93
 */
94
function calculateShellGroupStatus(
145✔
95
  aiShells: Shell[],
145✔
96
  shellActivityTimestamps: Record<string, number>,
145✔
97
  idleTimeoutMs: number,
145✔
98
): ProjectAiStatus {
145✔
99
  if (aiShells.length === 0) {
145✔
100
    return null;
125✔
101
  }
125✔
102

103
  let hasRed = false;
20✔
104
  let hasGreen = false;
20✔
105
  let hasBlue = false;
20✔
106

107
  for (const shell of aiShells) {
20✔
108
    if (shell.done) {
20✔
109
      hasBlue = true;
4✔
110
    } else {
20✔
111
      const isActive = isShellRecentlyActive(
16✔
112
        shell,
16✔
113
        shellActivityTimestamps[shell.id],
16✔
114
        idleTimeoutMs,
16✔
115
      );
16✔
116
      if (isActive) {
16✔
117
        hasGreen = true;
6✔
118
      } else {
16✔
119
        hasRed = true;
10✔
120
      }
10✔
121
    }
16✔
122
  }
20✔
123

124
  // Priority: red > green > blue
125
  if (hasRed) return 'red';
34✔
126
  if (hasGreen) return 'green';
34✔
127
  if (hasBlue) return 'blue';
4!
128
  return null;
×
129
}
×
130

131
/**
132
 * Hook to determine the aggregated AI status for a project
133
 *
134
 * Status aggregation:
135
 * 1. Direct AI shells (shells with no worktreePath) contribute to project status
136
 * 2. Each worktree's status is calculated from its AI shells
137
 *    - If worktree is marked done → 'blue' (regardless of shell activity)
138
 *    - Otherwise, status comes from worktree's AI shells
139
 * 3. All statuses are aggregated with priority: red > green > blue > null
140
 *
141
 * @param shells - All shells for the project (including worktree shells)
142
 * @param worktrees - Worktrees with their done status (optional, for status aggregation)
143
 * @param idleTimeoutMs - Timeout before AI shell is considered idle (default: 5000)
144
 * @returns Aggregated status: 'red' | 'green' | 'blue' | null
145
 */
146
export function useProjectAiStatus(
1✔
147
  shells: Shell[],
242✔
148
  worktrees: WorktreeStatusInfo[] = [],
242✔
149
  idleTimeoutMs = 5000,
242✔
150
): ProjectAiStatus {
242✔
151
  const [status, setStatus] = useState<ProjectAiStatus>(null);
242✔
152
  const shellActivityTimestamps = useUIStore((state) => state.shellActivityTimestamps);
242✔
153

154
  useEffect(() => {
242✔
155
    // Separate direct project shells from worktree shells
156
    const directShells = shells.filter((s) => s.worktreePath == null);
134✔
157
    const directAiShells = directShells.filter((s) => s.type === 'ai');
134✔
158

159
    const checkStatus = (): void => {
134✔
160
      const allStatuses: ProjectAiStatus[] = [];
134✔
161

162
      // 1. Calculate status for direct AI shells
163
      const directStatus = calculateShellGroupStatus(directAiShells, shellActivityTimestamps, idleTimeoutMs);
134✔
164
      if (directStatus !== null) {
134✔
165
        allStatuses.push(directStatus);
10✔
166
      }
10✔
167

168
      // 2. Calculate status for each worktree
169
      for (const worktree of worktrees) {
134✔
170
        if (worktree.done) {
19✔
171
          // Worktree marked as done → blue
172
          allStatuses.push('blue');
8✔
173
        } else {
19✔
174
          // Get shells for this worktree
175
          const worktreeShells = shells.filter((s) => s.worktreePath === worktree.path);
11✔
176
          const worktreeAiShells = worktreeShells.filter((s) => s.type === 'ai');
11✔
177
          const worktreeStatus = calculateShellGroupStatus(worktreeAiShells, shellActivityTimestamps, idleTimeoutMs);
11✔
178
          if (worktreeStatus !== null) {
11✔
179
            allStatuses.push(worktreeStatus);
10✔
180
          }
10✔
181
        }
11✔
182
      }
19✔
183

184
      // 3. Aggregate all statuses with priority: red > green > blue
185
      if (allStatuses.length === 0) {
134✔
186
        setStatus(null);
114✔
187
        return;
114✔
188
      }
114✔
189

190
      if (allStatuses.includes('red')) {
23✔
191
        setStatus('red');
10✔
192
      } else if (allStatuses.includes('green')) {
10✔
193
        setStatus('green');
6✔
194
      } else if (allStatuses.includes('blue')) {
10✔
195
        setStatus('blue');
4✔
196
      } else {
4!
UNCOV
197
        setStatus(null);
×
UNCOV
198
      }
×
199
    };
134✔
200

201
    // Check immediately
202
    checkStatus();
134✔
203

204
    // Set up interval to re-check periodically
205
    const interval = setInterval(checkStatus, 1000);
134✔
206

207
    return (): void => {
134✔
208
      clearInterval(interval);
134✔
209
    };
134✔
210
  }, [shells, worktrees, shellActivityTimestamps, idleTimeoutMs]);
242✔
211

212
  return status;
242✔
213
}
242✔
214

215
export function ShellItem({ shell, projectId, aiIdleTimeoutMs = 5000, depth = 1 }: ShellItemProps): React.ReactElement {
1✔
216
  const { activeShellId, setActiveShell } = useActiveShellId();
101✔
217
  const toggleContextSidebar = useUIStore((state) => state.toggleContextSidebar);
101✔
218
  const deleteShellMutation = useDeleteShell();
101✔
219
  const updateShellMutation = useUpdateShell();
101✔
220
  const restartShellMutation = useRestartShell();
101✔
221
  const [renameModalOpen, setRenameModalOpen] = useState(false);
101✔
222
  const [newName, setNewName] = useState(shell.name);
101✔
223
  const [contextMenuOpened, setContextMenuOpened] = useState(false);
101✔
224
  const [isHovered, setIsHovered] = useState(false);
101✔
225
  const isAiRecentlyActive = useAiShellActivity(shell, aiIdleTimeoutMs);
101✔
226

227
  const isActive = activeShellId === shell.id;
101✔
228
  const isAiShell = shell.type === 'ai';
101✔
229

230
  const handleClick = (): void => {
101✔
231
    console.log(`[TERMINAL_SWITCH] ${performance.now().toFixed(2)}ms - Click on shell: ${shell.id} (${shell.name})`);
4✔
232
    (window as unknown as { __terminalSwitchStart?: number }).__terminalSwitchStart = performance.now();
4✔
233
    setActiveShell(shell.id);
4✔
234
    toggleContextSidebar('shell', shell.id);
4✔
235
  };
4✔
236

237
  const handleDelete = (): void => {
101✔
238
    deleteShellMutation.mutate({ shellId: shell.id, projectId });
1✔
239
  };
1✔
240

241
  const handleRenameClick = (): void => {
101✔
242
    setNewName(shell.name);
1✔
243
    setRenameModalOpen(true);
1✔
244
  };
1✔
245

246
  const handleRenameSubmit = (): void => {
101✔
247
    if (!newName.trim() || newName === shell.name) {
1!
UNCOV
248
      setRenameModalOpen(false);
×
UNCOV
249
      return;
×
UNCOV
250
    }
×
251
    updateShellMutation.mutate(
1✔
252
      { shellId: shell.id, updates: { name: newName.trim() } },
1✔
253
      {
1✔
254
        onSuccess: () => {
1✔
255
          setRenameModalOpen(false);
1✔
256
        },
1✔
257
      },
1✔
258
    );
1✔
259
  };
1✔
260

261
  const handleRestart = (): void => {
101✔
262
    restartShellMutation.mutate(shell.id);
1✔
263
  };
1✔
264

265
  const handleToggleDone = (): void => {
101✔
266
    updateShellMutation.mutate({
2✔
267
      shellId: shell.id,
2✔
268
      updates: { done: !shell.done },
2✔
269
    });
2✔
270
  };
2✔
271

272
  const statusColor = shell.status === 'active' ? 'green' : shell.status === 'error' ? 'red' : 'gray';
101!
273

274
  // Activity indicator color for AI shells
275
  // Blue when done, green when active, red when idle
276
  const activityIndicatorColor = shell.done
101✔
277
    ? 'var(--mantine-color-blue-6)'
15✔
278
    : isAiRecentlyActive
86✔
279
      ? 'var(--mantine-color-green-6)'
3✔
280
      : 'var(--mantine-color-red-6)';
83✔
281

282
  const handleContextMenu = (e: React.MouseEvent): void => {
101✔
UNCOV
283
    e.preventDefault();
×
UNCOV
284
    e.stopPropagation();
×
UNCOV
285
    setContextMenuOpened(true);
×
UNCOV
286
  };
×
287

288
  // Build the icon based on shell type
289
  const icon = isAiShell ? (
101✔
290
    <IconSparkles size={14} style={{ color: 'var(--mantine-color-violet-4)' }} />
39✔
291
  ) : (
292
    <IconTerminal2 size={14} style={{ color: 'var(--mantine-color-green-4)' }} />
62✔
293
  );
294

295
  // Menu for shell options (always visible)
296
  const menu = (
101✔
297
    <Menu position="bottom-end" withinPortal opened={contextMenuOpened} onClose={(): void => { setContextMenuOpened(false); }}>
101✔
298
      <Menu.Target>
101✔
299
        <ActionIcon
101✔
300
          variant="subtle"
101✔
301
          size="xs"
101✔
302
          onClick={(e): void => { e.stopPropagation(); setContextMenuOpened(true); }}
101✔
303
        >
304
          <IconDots size={12} />
101✔
305
        </ActionIcon>
101✔
306
      </Menu.Target>
101✔
307
      <Menu.Dropdown>
101✔
308
        <Menu.Item
101✔
309
          leftSection={<IconPencil size={14} />}
101✔
310
          onClick={handleRenameClick}
101✔
311
        >
101✔
312
          Rename
313
        </Menu.Item>
101✔
314
        <Menu.Item
101✔
315
          leftSection={<IconRefresh size={14} />}
101✔
316
          onClick={handleRestart}
101✔
317
        >
101✔
318
          Restart
319
        </Menu.Item>
101✔
320
        {isAiShell && (
101✔
321
          <Menu.Item
39✔
322
            leftSection={shell.done ? <IconPlayerPlay size={14} /> : <IconCheck size={14} />}
39✔
323
            onClick={handleToggleDone}
39✔
324
          >
325
            {shell.done ? 'Mark as Active' : 'Mark as Done'}
39✔
326
          </Menu.Item>
39✔
327
        )}
328
        <Menu.Divider />
101✔
329
        <Menu.Item
101✔
330
          color="red"
101✔
331
          leftSection={<IconTrash size={14} />}
101✔
332
          onClick={handleDelete}
101✔
333
        >
101✔
334
          Close Shell
335
        </Menu.Item>
101✔
336
      </Menu.Dropdown>
101✔
337
    </Menu>
101✔
338
  );
339

340
  // Build badges for display (includes status badge and menu)
341
  const badges = (
101✔
342
    <>
101✔
343
      <Badge size="xs" variant="dot" color={statusColor}>
101✔
344
        {shell.status}
101✔
345
      </Badge>
101✔
346
      {menu}
101✔
347
    </>
101✔
348
  );
349

350
  return (
101✔
351
    <>
101✔
352
      <TreeItem
101✔
353
        label={shell.name}
101✔
354
        icon={icon}
101✔
355
        onClick={handleClick}
101✔
356
        onContextMenu={handleContextMenu}
101✔
357
        depth={depth}
101✔
358
        statusColor={isAiShell ? activityIndicatorColor : null}
101✔
359
        selected={isActive}
101✔
360
        hovered={isHovered}
101✔
361
        onHoverChange={setIsHovered}
101✔
362
        strikethrough={shell.done}
101✔
363
        dimmed={shell.done}
101✔
364
        badges={badges}
101✔
365
        testId="shell-item"
101✔
366
        nameTestId="shell-name"
101✔
367
      />
101✔
368

369
      <Modal
101✔
370
        opened={renameModalOpen}
101✔
371
        onClose={() => { setRenameModalOpen(false); }}
101✔
372
        title="Rename Shell"
101✔
373
        size="sm"
101✔
374
      >
375
        <Stack>
101✔
376
          <TextInput
101✔
377
            label="Shell name"
101✔
378
            value={newName}
101✔
379
            onChange={(e) => { setNewName(e.currentTarget.value); }}
101✔
380
            placeholder="Enter shell name"
101✔
381
            autoFocus
101✔
382
            onKeyDown={(e) => {
101✔
383
              if (e.key === 'Enter') {
8!
UNCOV
384
                handleRenameSubmit();
×
UNCOV
385
              }
×
386
            }}
8✔
387
          />
101✔
388
          <Group justify="flex-end">
101✔
389
            <Button variant="subtle" onClick={() => { setRenameModalOpen(false); }}>
101✔
390
              Cancel
391
            </Button>
101✔
392
            <Button onClick={handleRenameSubmit} loading={updateShellMutation.isPending}>
101✔
393
              Rename
394
            </Button>
101✔
395
          </Group>
101✔
396
        </Stack>
101✔
397
      </Modal>
101✔
398
    </>
101✔
399
  );
400
}
101✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc