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

apowers313 / aiforge / 21002763057

14 Jan 2026 05:00PM UTC coverage: 82.93% (-1.8%) from 84.765%
21002763057

push

github

apowers313
chore: delint

993 of 1165 branches covered (85.24%)

Branch coverage included in aggregate %.

5206 of 6310 relevant lines covered (82.5%)

15.95 hits per line

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

84.36
/src/client/components/shells/ShellItem.tsx
1
import { useState, useEffect } from 'react';
1✔
2
import { Box, Group, Text, UnstyledButton, ActionIcon, Menu, Badge, Modal, TextInput, Button, Stack } 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

8
interface ShellItemProps {
9
  shell: Shell;
10
  projectId: string;
11
  /** Timeout in milliseconds before AI shell is considered idle (default: 5000) */
12
  aiIdleTimeoutMs?: number;
13
  /** Offset in pixels for positioning the AI activity indicator in the left gutter */
14
  indicatorOffset?: number;
15
}
16

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

27
  if (clientActivityTimestamp) {
13✔
28
    lastActivityTime = clientActivityTimestamp;
4✔
29
  } else if (shell.lastActivityAt) {
13✔
30
    lastActivityTime = new Date(shell.lastActivityAt).getTime();
1✔
31
  }
1✔
32

33
  if (!lastActivityTime) {
13✔
34
    return false;
8✔
35
  }
8✔
36

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

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

51
  useEffect(() => {
69✔
52
    if (shell.type !== 'ai') {
30✔
53
      return;
18✔
54
    }
18✔
55

56
    const checkActivity = (): void => {
12✔
57
      const isActive = isShellRecentlyActive(shell, clientActivityTimestamp, idleTimeoutMs);
13✔
58
      setIsRecentlyActive(isActive);
13✔
59
    };
13✔
60

61
    // Check immediately
62
    checkActivity();
12✔
63

64
    // Set up interval to re-check periodically
65
    const interval = setInterval(checkActivity, 1000);
12✔
66

67
    return (): void => {
12✔
68
      clearInterval(interval);
12✔
69
    };
12✔
70
  }, [shell.type, shell.id, shell.lastActivityAt, clientActivityTimestamp, idleTimeoutMs]);
69✔
71

72
  return isRecentlyActive;
69✔
73
}
69✔
74

75
export type ProjectAiStatus = 'red' | 'green' | 'blue' | null;
76

77
/**
78
 * Hook to determine the aggregated AI status for a project
79
 * Returns:
80
 * - 'red' if any AI shell is idle (not done, not recently active)
81
 * - 'green' if any AI shell is active (not done, recently active)
82
 * - 'blue' if all AI shells are done
83
 * - null if there are no AI shells
84
 */
85
export function useProjectAiStatus(shells: Shell[], idleTimeoutMs = 5000): ProjectAiStatus {
1✔
86
  const [status, setStatus] = useState<ProjectAiStatus>(null);
40✔
87
  const shellActivityTimestamps = useUIStore((state) => state.shellActivityTimestamps);
40✔
88

89
  useEffect(() => {
40✔
90
    const aiShells = shells.filter((s) => s.type === 'ai');
24✔
91

92
    if (aiShells.length === 0) {
24✔
93
      setStatus(null);
24✔
94
      return;
24✔
95
    }
24!
96

97
    const checkStatus = (): void => {
×
98
      let hasRed = false;
×
99
      let hasGreen = false;
×
100
      let hasBlue = false;
×
101

102
      for (const shell of aiShells) {
×
103
        if (shell.done) {
×
104
          hasBlue = true;
×
105
        } else {
×
106
          const isActive = isShellRecentlyActive(
×
107
            shell,
×
108
            shellActivityTimestamps[shell.id],
×
109
            idleTimeoutMs,
×
110
          );
×
111
          if (isActive) {
×
112
            hasGreen = true;
×
113
          } else {
×
114
            hasRed = true;
×
115
          }
×
116
        }
×
117
      }
×
118

119
      // Priority: red > green > blue
120
      if (hasRed) {
×
121
        setStatus('red');
×
122
      } else if (hasGreen) {
×
123
        setStatus('green');
×
124
      } else if (hasBlue) {
×
125
        setStatus('blue');
×
126
      } else {
×
127
        setStatus(null);
×
128
      }
×
129
    };
×
130

131
    // Check immediately
132
    checkStatus();
×
133

134
    // Set up interval to re-check periodically
135
    const interval = setInterval(checkStatus, 1000);
×
136

137
    return (): void => {
×
138
      clearInterval(interval);
×
139
    };
×
140
  }, [shells, shellActivityTimestamps, idleTimeoutMs]);
40✔
141

142
  return status;
40✔
143
}
40✔
144

145
export function ShellItem({ shell, projectId, aiIdleTimeoutMs = 5000, indicatorOffset = 7 }: ShellItemProps): React.ReactElement {
1✔
146
  const { activeShellId, setActiveShell } = useActiveShellId();
69✔
147
  const deleteShellMutation = useDeleteShell();
69✔
148
  const updateShellMutation = useUpdateShell();
69✔
149
  const restartShellMutation = useRestartShell();
69✔
150
  const [renameModalOpen, setRenameModalOpen] = useState(false);
69✔
151
  const [newName, setNewName] = useState(shell.name);
69✔
152
  const [contextMenuOpened, setContextMenuOpened] = useState(false);
69✔
153
  const isAiRecentlyActive = useAiShellActivity(shell, aiIdleTimeoutMs);
69✔
154

155
  const isActive = activeShellId === shell.id;
69✔
156
  const isAiShell = shell.type === 'ai';
69✔
157

158
  const handleClick = (): void => {
69✔
159
    console.log(`[TERMINAL_SWITCH] ${performance.now().toFixed(2)}ms - Click on shell: ${shell.id} (${shell.name})`);
1✔
160
    (window as unknown as { __terminalSwitchStart?: number }).__terminalSwitchStart = performance.now();
1✔
161
    setActiveShell(shell.id);
1✔
162
  };
1✔
163

164
  const handleDelete = (): void => {
69✔
165
    deleteShellMutation.mutate({ shellId: shell.id, projectId });
1✔
166
  };
1✔
167

168
  const handleRenameClick = (): void => {
69✔
169
    setNewName(shell.name);
1✔
170
    setRenameModalOpen(true);
1✔
171
  };
1✔
172

173
  const handleRenameSubmit = (): void => {
69✔
174
    if (!newName.trim() || newName === shell.name) {
1!
175
      setRenameModalOpen(false);
×
176
      return;
×
177
    }
×
178
    updateShellMutation.mutate(
1✔
179
      { shellId: shell.id, updates: { name: newName.trim() } },
1✔
180
      {
1✔
181
        onSuccess: () => {
1✔
182
          setRenameModalOpen(false);
1✔
183
        },
1✔
184
      },
1✔
185
    );
1✔
186
  };
1✔
187

188
  const handleRestart = (): void => {
69✔
189
    restartShellMutation.mutate(shell.id);
1✔
190
  };
1✔
191

192
  const handleToggleDone = (): void => {
69✔
193
    updateShellMutation.mutate({
2✔
194
      shellId: shell.id,
2✔
195
      updates: { done: !shell.done },
2✔
196
    });
2✔
197
  };
2✔
198

199
  const statusColor = shell.status === 'active' ? 'green' : shell.status === 'error' ? 'red' : 'gray';
69!
200

201
  // Activity indicator color for AI shells
202
  // Blue when done, green when active, red when idle
203
  const activityIndicatorColor = shell.done
69✔
204
    ? 'var(--mantine-color-blue-6)'
11✔
205
    : isAiRecentlyActive
58✔
206
      ? 'var(--mantine-color-green-6)'
3✔
207
      : 'var(--mantine-color-red-6)';
55✔
208

209
  const handleContextMenu = (e: React.MouseEvent): void => {
69✔
210
    e.preventDefault();
×
211
    e.stopPropagation();
×
212
    setContextMenuOpened(true);
×
213
  };
×
214

215
  return (
69✔
216
    <>
69✔
217
      <Box
69✔
218
        style={{
69✔
219
          display: 'flex',
69✔
220
          alignItems: 'stretch',
69✔
221
          position: 'relative',
69✔
222
        }}
69✔
223
        onContextMenu={handleContextMenu}
69✔
224
      >
225
        {/* Activity indicator for AI shells - positioned in the left gutter */}
226
        {isAiShell && (
69✔
227
          <Box
29✔
228
            style={{
29✔
229
              position: 'absolute',
29✔
230
              left: -indicatorOffset,
29✔
231
              top: 0,
29✔
232
              bottom: 0,
29✔
233
              width: 3,
29✔
234
              backgroundColor: activityIndicatorColor,
29✔
235
              borderRadius: 'var(--mantine-radius-xs)',
29✔
236
            }}
29✔
237
            data-testid="ai-activity-indicator"
29✔
238
          />
29✔
239
        )}
240
        <Group gap={0} wrap="nowrap" style={{ flex: 1 }}>
69✔
241
          <UnstyledButton
69✔
242
            onClick={handleClick}
69✔
243
            style={{
69✔
244
              flex: 1,
69✔
245
              padding: '6px 10px',
69✔
246
              borderRadius: 'var(--mantine-radius-sm)',
69✔
247
              backgroundColor: isActive ? 'var(--mantine-color-dark-5)' : 'transparent',
69✔
248
              display: 'flex',
69✔
249
              alignItems: 'center',
69✔
250
              gap: 8,
69✔
251
            }}
69✔
252
            className="shell-item"
69✔
253
            data-testid="shell-item"
69✔
254
          >
255
            {isAiShell ? (
69✔
256
              <IconSparkles size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-violet-4)' }} />
29✔
257
            ) : (
258
              <IconTerminal2 size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-green-4)' }} />
40✔
259
            )}
260
            <Text size="xs" truncate style={{ flex: 1 }}>
69✔
261
              {shell.name}
69✔
262
            </Text>
69✔
263
            <Badge size="xs" variant="dot" color={statusColor}>
69✔
264
              {shell.status}
69✔
265
            </Badge>
69✔
266
          </UnstyledButton>
69✔
267

268
          <Menu position="bottom-end" withinPortal opened={contextMenuOpened} onClose={() => { setContextMenuOpened(false); }}>
69✔
269
            <Menu.Target>
69✔
270
              <ActionIcon
69✔
271
                variant="subtle"
69✔
272
                size="xs"
69✔
273
                onClick={(e) => { e.stopPropagation(); setContextMenuOpened(true); }}
69✔
274
              >
275
                <IconDots size={12} />
69✔
276
              </ActionIcon>
69✔
277
            </Menu.Target>
69✔
278
            <Menu.Dropdown>
69✔
279
              <Menu.Item
69✔
280
                leftSection={<IconPencil size={14} />}
69✔
281
                onClick={handleRenameClick}
69✔
282
              >
69✔
283
                Rename
284
              </Menu.Item>
69✔
285
              <Menu.Item
69✔
286
                leftSection={<IconRefresh size={14} />}
69✔
287
                onClick={handleRestart}
69✔
288
              >
69✔
289
                Restart
290
              </Menu.Item>
69✔
291
              {isAiShell && (
69✔
292
                <Menu.Item
29✔
293
                  leftSection={shell.done ? <IconPlayerPlay size={14} /> : <IconCheck size={14} />}
29✔
294
                  onClick={handleToggleDone}
29✔
295
                >
296
                  {shell.done ? 'Mark as Active' : 'Mark as Done'}
29✔
297
                </Menu.Item>
29✔
298
              )}
299
              <Menu.Divider />
69✔
300
              <Menu.Item
69✔
301
                color="red"
69✔
302
                leftSection={<IconTrash size={14} />}
69✔
303
                onClick={handleDelete}
69✔
304
              >
69✔
305
                Close Shell
306
              </Menu.Item>
69✔
307
            </Menu.Dropdown>
69✔
308
          </Menu>
69✔
309
        </Group>
69✔
310
      </Box>
69✔
311

312
      <Modal
69✔
313
        opened={renameModalOpen}
69✔
314
        onClose={() => { setRenameModalOpen(false); }}
69✔
315
        title="Rename Shell"
69✔
316
        size="sm"
69✔
317
      >
318
        <Stack>
69✔
319
          <TextInput
69✔
320
            label="Shell name"
69✔
321
            value={newName}
69✔
322
            onChange={(e) => { setNewName(e.currentTarget.value); }}
69✔
323
            placeholder="Enter shell name"
69✔
324
            autoFocus
69✔
325
            onKeyDown={(e) => {
69✔
326
              if (e.key === 'Enter') {
8!
327
                handleRenameSubmit();
×
328
              }
×
329
            }}
8✔
330
          />
69✔
331
          <Group justify="flex-end">
69✔
332
            <Button variant="subtle" onClick={() => { setRenameModalOpen(false); }}>
69✔
333
              Cancel
334
            </Button>
69✔
335
            <Button onClick={handleRenameSubmit} loading={updateShellMutation.isPending}>
69✔
336
              Rename
337
            </Button>
69✔
338
          </Group>
69✔
339
        </Stack>
69✔
340
      </Modal>
69✔
341
    </>
69✔
342
  );
343
}
69✔
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