tycono 0.1.96-beta.2 → 0.1.96-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * CommandMode — chat-first mode
3
+ *
4
+ * User = CEO. Supervisor (ceo role) = user's AI proxy.
5
+ * - Supervisor responses: shown directly (no prefix), like a conversation
6
+ * - Team activity: indented with roleId, concise
7
+ * - System prompts, internal noise: filtered out
8
+ */
9
+
10
+ import React, { useState, useCallback } from 'react';
11
+ import { Box, Text } from 'ink';
12
+ import TextInput from 'ink-text-input';
13
+ import type { SSEEvent } from '../api';
14
+ import { getRoleColor } from '../theme';
15
+
16
+ const MAX_STREAM_LINES = 30;
17
+ const SUPERVISOR_ROLE = 'ceo';
18
+
19
+ export interface StreamLine {
20
+ id: number;
21
+ text: string;
22
+ color: string;
23
+ prefix?: string;
24
+ prefixColor?: string;
25
+ indent?: boolean;
26
+ }
27
+
28
+ interface CommandModeProps {
29
+ events: SSEEvent[];
30
+ allRoleIds: string[];
31
+ systemMessages: StreamLine[];
32
+ onSubmit: (input: string) => void;
33
+ }
34
+
35
+ let lineCounter = 0;
36
+
37
+ /** Filter out system prompt noise from text */
38
+ function isSystemNoise(text: string): boolean {
39
+ const t = text.trim();
40
+ if (!t) return true;
41
+ // System prompt fragments
42
+ if (t.startsWith('## Your Role')) return true;
43
+ if (t.startsWith('You are')) return true;
44
+ if (t.startsWith('[CEO Supervisor]')) return true;
45
+ if (t.startsWith('[Question from')) return true;
46
+ if (t.includes('⛔ AKB Rule')) return true;
47
+ if (t.includes('⛔ Read the')) return true;
48
+ if (t.startsWith('⛔')) return true;
49
+ return false;
50
+ }
51
+
52
+ /** Convert SSE event to stream lines */
53
+ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLine | null {
54
+ const isSupervisor = event.roleId === SUPERVISOR_ROLE;
55
+ const roleColor = getRoleColor(event.roleId, allRoleIds);
56
+
57
+ switch (event.type) {
58
+ case 'text': {
59
+ const text = ((event.data.text as string) ?? '');
60
+ if (isSystemNoise(text)) return null;
61
+
62
+ if (isSupervisor) {
63
+ // Supervisor text → direct response (no prefix, generous length)
64
+ return {
65
+ id: ++lineCounter,
66
+ text: text.slice(0, 200),
67
+ color: 'white',
68
+ };
69
+ } else {
70
+ // Team text → indented with role prefix, concise
71
+ return {
72
+ id: ++lineCounter,
73
+ prefix: event.roleId,
74
+ prefixColor: roleColor,
75
+ text: text.slice(0, 80),
76
+ color: 'white',
77
+ indent: true,
78
+ };
79
+ }
80
+ }
81
+
82
+ case 'dispatch:start': {
83
+ const target = (event.data.targetRole as string) ?? '';
84
+ const task = ((event.data.task as string) ?? '');
85
+ // Filter out system prompt from task display
86
+ const cleanTask = task.replace(/⛔[^⛔]*⛔[^"]*/g, '').trim().slice(0, 50);
87
+ if (isSupervisor) {
88
+ return {
89
+ id: ++lineCounter,
90
+ text: `→ ${target} 배정${cleanTask ? ': ' + cleanTask : ''}`,
91
+ color: 'yellow',
92
+ };
93
+ }
94
+ return {
95
+ id: ++lineCounter,
96
+ prefix: event.roleId,
97
+ prefixColor: roleColor,
98
+ text: `→ ${target} 배정`,
99
+ color: 'yellow',
100
+ indent: true,
101
+ };
102
+ }
103
+
104
+ case 'dispatch:done': {
105
+ const target = (event.data.targetRole as string) ?? '';
106
+ return {
107
+ id: ++lineCounter,
108
+ prefix: event.roleId,
109
+ prefixColor: roleColor,
110
+ text: `← ${target} 완료`,
111
+ color: 'yellow',
112
+ indent: !isSupervisor,
113
+ };
114
+ }
115
+
116
+ case 'tool:start': {
117
+ const toolName = (event.data.name as string) ?? 'tool';
118
+ const input = event.data.input;
119
+ let detail = '';
120
+ if (input && typeof input === 'object') {
121
+ const inp = input as Record<string, unknown>;
122
+ if (inp.file_path) detail = ` ${String(inp.file_path).split('/').pop()}`;
123
+ else if (inp.command) detail = ` ${String(inp.command).slice(0, 40)}`;
124
+ }
125
+
126
+ if (isSupervisor) {
127
+ // Supervisor tool use → subtle
128
+ return {
129
+ id: ++lineCounter,
130
+ text: ` → ${toolName}${detail}`,
131
+ color: 'gray',
132
+ };
133
+ }
134
+ return {
135
+ id: ++lineCounter,
136
+ prefix: event.roleId,
137
+ prefixColor: roleColor,
138
+ text: `→ ${toolName}${detail}`,
139
+ color: 'gray',
140
+ indent: true,
141
+ };
142
+ }
143
+
144
+ case 'msg:start': {
145
+ if (isSupervisor) return null; // Hide supervisor start (noise)
146
+ const task = ((event.data.task as string) ?? '');
147
+ const cleanTask = task.replace(/⛔[^⛔]*⛔[^"]*/g, '').trim().slice(0, 40);
148
+ return {
149
+ id: ++lineCounter,
150
+ prefix: event.roleId,
151
+ prefixColor: roleColor,
152
+ text: `▶ ${cleanTask || 'started'}`,
153
+ color: 'green',
154
+ indent: true,
155
+ };
156
+ }
157
+
158
+ case 'msg:done': {
159
+ const turns = event.data.turns as number | undefined;
160
+ if (isSupervisor) return null; // Hide supervisor done
161
+ return {
162
+ id: ++lineCounter,
163
+ prefix: event.roleId,
164
+ prefixColor: roleColor,
165
+ text: `✓ done${turns ? ` (${turns} turns)` : ''}`,
166
+ color: 'green',
167
+ indent: true,
168
+ };
169
+ }
170
+
171
+ case 'msg:error': {
172
+ if (isSupervisor) return null; // Supervisor errors handled by system messages
173
+ const error = ((event.data.error as string) ?? '').slice(0, 60);
174
+ return {
175
+ id: ++lineCounter,
176
+ prefix: event.roleId,
177
+ prefixColor: roleColor,
178
+ text: `✗ ${error}`,
179
+ color: 'red',
180
+ indent: true,
181
+ };
182
+ }
183
+
184
+ case 'msg:awaiting_input':
185
+ return {
186
+ id: ++lineCounter,
187
+ text: isSupervisor ? '...' : ` ${event.roleId}: waiting`,
188
+ color: 'yellow',
189
+ };
190
+
191
+ // Hidden
192
+ case 'thinking':
193
+ case 'heartbeat:tick':
194
+ case 'heartbeat:skip':
195
+ case 'prompt:assembled':
196
+ case 'trace:response':
197
+ case 'tool:result':
198
+ return null;
199
+
200
+ default:
201
+ return null;
202
+ }
203
+ }
204
+
205
+ export const CommandMode: React.FC<CommandModeProps> = ({
206
+ events,
207
+ allRoleIds,
208
+ systemMessages,
209
+ onSubmit,
210
+ }) => {
211
+ const [input, setInput] = useState('');
212
+
213
+ // Convert events to stream lines
214
+ const eventLines: StreamLine[] = [];
215
+ for (const event of events) {
216
+ const line = summarizeEvent(event, allRoleIds);
217
+ if (line) eventLines.push(line);
218
+ }
219
+
220
+ // Merge system messages and event lines, show last MAX_STREAM_LINES
221
+ const allLines = [...systemMessages, ...eventLines].slice(-MAX_STREAM_LINES);
222
+
223
+ const handleSubmit = useCallback((value: string) => {
224
+ const trimmed = value.trim();
225
+ if (trimmed) {
226
+ onSubmit(trimmed);
227
+ }
228
+ setInput('');
229
+ }, [onSubmit]);
230
+
231
+ return (
232
+ <Box flexDirection="column" flexGrow={1}>
233
+ {/* Stream area */}
234
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
235
+ {allLines.length === 0 && (
236
+ <Box marginTop={1}>
237
+ <Text color="gray" dimColor>
238
+ Type a message to your AI team, or /help for commands.
239
+ </Text>
240
+ </Box>
241
+ )}
242
+ {allLines.map((line) => (
243
+ <Box key={line.id}>
244
+ {line.indent && <Text> </Text>}
245
+ {line.prefix && (
246
+ <Text color={line.prefixColor} bold>
247
+ {(line.prefix).padEnd(12)}
248
+ </Text>
249
+ )}
250
+ <Text color={line.color}>{line.text}</Text>
251
+ </Box>
252
+ ))}
253
+ </Box>
254
+
255
+ {/* Separator */}
256
+ <Box width="100%">
257
+ <Text color="gray">{'─'.repeat(process.stdout.columns || 70)}</Text>
258
+ </Box>
259
+
260
+ {/* Input */}
261
+ <Box paddingX={1} justifyContent="space-between">
262
+ <Box>
263
+ <Text color="yellow" bold>&gt; </Text>
264
+ <TextInput
265
+ value={input}
266
+ onChange={setInput}
267
+ onSubmit={handleSubmit}
268
+ placeholder=""
269
+ />
270
+ </Box>
271
+ <Box>
272
+ <Text color="gray" dimColor>[Tab] panel</Text>
273
+ </Box>
274
+ </Box>
275
+ </Box>
276
+ );
277
+ };
@@ -32,7 +32,6 @@ function statusColor(status: string): string {
32
32
 
33
33
  interface FlatEntry {
34
34
  roleId: string;
35
- name: string;
36
35
  level: string;
37
36
  status: string;
38
37
  prefix: string;
@@ -47,15 +46,14 @@ function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] =
47
46
 
48
47
  let linePrefix = '';
49
48
  for (let j = 0; j < isLast.length; j++) {
50
- linePrefix += isLast[j] ? ' ' : '\u2502 ';
49
+ linePrefix += isLast[j] ? ' ' : ' ';
51
50
  }
52
51
  linePrefix += isLast.length > 0 || i > 0 || nodes.length > 1
53
- ? (last ? '\u2514\u2500 ' : '\u251C\u2500 ')
52
+ ? (last ? '└─ ' : '├─ ')
54
53
  : '';
55
54
 
56
55
  result.push({
57
56
  roleId: node.role.id,
58
- name: node.role.name || node.role.id,
59
57
  level: node.role.level,
60
58
  status: node.status,
61
59
  prefix: linePrefix,
@@ -72,19 +70,9 @@ function flattenTree(nodes: OrgNode[], prefix: string = '', isLast: boolean[] =
72
70
  export const OrgTree: React.FC<OrgTreeProps> = ({ tree, focused, selectedIndex, flatRoles }) => {
73
71
  const entries = flattenTree(tree);
74
72
 
75
- // Map flatRoles index to entries
76
- const flatRoleIdToEntryIdx = new Map<number, number>();
77
- let roleIdx = 0;
78
- for (let i = 0; i < entries.length; i++) {
79
- if (roleIdx < flatRoles.length && flatRoles[roleIdx] === entries[i].roleId) {
80
- flatRoleIdToEntryIdx.set(roleIdx, i);
81
- roleIdx++;
82
- }
83
- }
84
-
85
73
  return (
86
74
  <Box flexDirection="column" paddingX={1}>
87
- <Text bold color={focused ? 'cyan' : 'gray'}>\u2500\u2500 Org Tree \u2500\u2500</Text>
75
+ <Text bold color={focused ? 'cyan' : 'gray'}>{'── Org Tree ──'}</Text>
88
76
  <Box marginTop={1}>
89
77
  <Text color="yellow" bold>{'\uD83D\uDC51'} CEO</Text>
90
78
  </Box>
@@ -108,9 +96,8 @@ export const OrgTree: React.FC<OrgTreeProps> = ({ tree, focused, selectedIndex,
108
96
  bold={isSelected}
109
97
  inverse={isSelected}
110
98
  >
111
- {entry.level === 'c-level' ? entry.name : entry.name}
99
+ {entry.roleId}
112
100
  </Text>
113
- <Text color="gray" dimColor> {entry.roleId}</Text>
114
101
  </Box>
115
102
  );
116
103
  })}
@@ -0,0 +1,265 @@
1
+ /**
2
+ * PanelMode — Tab view: Org Tree (left) + Agent Detail + Stream (right)
3
+ *
4
+ * Left: Org Tree with status icons
5
+ * + compact resource summary (waves, ports)
6
+ * Right: Selected role's resource info (port, worktree, browser)
7
+ * + event stream
8
+ *
9
+ * Navigation:
10
+ * j/k or arrow keys — move in Org Tree
11
+ * Enter — select role to view its stream
12
+ * Esc — return to Command Mode
13
+ */
14
+
15
+ import React, { useState, useEffect } from 'react';
16
+ import { Box, Text, useInput } from 'ink';
17
+ import { OrgTree } from './OrgTree';
18
+ import { StreamView } from './StreamView';
19
+ import type { OrgNode } from '../store';
20
+ import type { SSEEvent, ActiveSessionInfo } from '../api';
21
+ import type { WaveInfo } from '../hooks/useCommand';
22
+
23
+ interface PanelModeProps {
24
+ tree: OrgNode[];
25
+ flatRoles: string[];
26
+ events: SSEEvent[];
27
+ selectedRoleIndex: number;
28
+ selectedRoleId: string | null;
29
+ streamStatus: 'idle' | 'streaming' | 'done' | 'error';
30
+ waveId: string | null;
31
+ activeSessions: ActiveSessionInfo[];
32
+ waves: WaveInfo[];
33
+ focusedWaveId: string | null;
34
+ portSummary: { active: number; totalPorts: number };
35
+ onMove: (direction: 'up' | 'down') => void;
36
+ onSelect: () => void;
37
+ onEscape: () => void;
38
+ }
39
+
40
+ /** Find active session for a given roleId */
41
+ function findSessionForRole(activeSessions: ActiveSessionInfo[], roleId: string): ActiveSessionInfo | null {
42
+ // Prefer active sessions, then any
43
+ return activeSessions.find(s => s.roleId === roleId && s.status === 'active')
44
+ ?? activeSessions.find(s => s.roleId === roleId)
45
+ ?? null;
46
+ }
47
+
48
+ /** Format elapsed time */
49
+ function elapsed(startedAt: string): string {
50
+ const ms = Date.now() - new Date(startedAt).getTime();
51
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`;
52
+ if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
53
+ return `${Math.floor(ms / 3600_000)}h`;
54
+ }
55
+
56
+ export const PanelMode: React.FC<PanelModeProps> = ({
57
+ tree,
58
+ flatRoles,
59
+ events,
60
+ selectedRoleIndex,
61
+ selectedRoleId,
62
+ streamStatus,
63
+ waveId,
64
+ activeSessions,
65
+ waves,
66
+ focusedWaveId,
67
+ portSummary,
68
+ onMove,
69
+ onSelect,
70
+ onEscape,
71
+ }) => {
72
+ // Track terminal height for vertical separator
73
+ const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
74
+ useEffect(() => {
75
+ const onResize = () => setTermHeight(process.stdout.rows || 30);
76
+ process.stdout.on('resize', onResize);
77
+ return () => { process.stdout.off('resize', onResize); };
78
+ }, []);
79
+
80
+ useInput((input, key) => {
81
+ if (key.escape) {
82
+ onEscape();
83
+ return;
84
+ }
85
+ if (key.upArrow || input === 'k') {
86
+ onMove('up');
87
+ return;
88
+ }
89
+ if (key.downArrow || input === 'j') {
90
+ onMove('down');
91
+ return;
92
+ }
93
+ if (key.return) {
94
+ onSelect();
95
+ return;
96
+ }
97
+ });
98
+
99
+ // Filter events for selected role
100
+ const roleEvents = selectedRoleId
101
+ ? events.filter((e) => e.roleId === selectedRoleId)
102
+ : events;
103
+
104
+ const roleLabel = selectedRoleId
105
+ ? flatRoles.includes(selectedRoleId) ? selectedRoleId : 'All'
106
+ : 'All';
107
+
108
+ // Find resource info for selected role
109
+ const selectedSession = selectedRoleId
110
+ ? findSessionForRole(activeSessions, selectedRoleId)
111
+ : null;
112
+
113
+ // Focused wave info
114
+ const focusedWave = waves.find(w => w.waveId === focusedWaveId);
115
+ const focusedWaveIndex = focusedWaveId
116
+ ? waves.findIndex(w => w.waveId === focusedWaveId) + 1
117
+ : 0;
118
+
119
+ // Count sessions per wave for summary
120
+ const waveSessionCounts = new Map<string, number>();
121
+ for (const s of activeSessions) {
122
+ if (s.waveId) {
123
+ waveSessionCounts.set(s.waveId, (waveSessionCounts.get(s.waveId) ?? 0) + 1);
124
+ }
125
+ }
126
+
127
+ return (
128
+ <Box flexDirection="column" flexGrow={1}>
129
+ {/* Main content: Org Tree left | Detail + Stream right */}
130
+ <Box flexGrow={1}>
131
+ {/* Left: Org Tree + Resource Summary */}
132
+ <Box flexDirection="column" width={28}>
133
+ <OrgTree
134
+ tree={tree}
135
+ focused={true}
136
+ selectedIndex={selectedRoleIndex}
137
+ flatRoles={flatRoles}
138
+ />
139
+
140
+ {/* Resource Summary — below org tree */}
141
+ <Box flexDirection="column" paddingX={1} marginTop={1}>
142
+ <Text color="gray">{'\u2500'.repeat(24)}</Text>
143
+
144
+ {/* Waves */}
145
+ {waves.length > 0 && (
146
+ <Box flexDirection="column">
147
+ {waves.map((w, i) => {
148
+ const isFocused = w.waveId === focusedWaveId;
149
+ const count = waveSessionCounts.get(w.waveId) ?? 0;
150
+ return (
151
+ <Box key={w.waveId}>
152
+ <Text color={isFocused ? 'green' : 'gray'}>
153
+ {isFocused ? '\u25B8' : ' '} W{i + 1}
154
+ </Text>
155
+ <Text color="gray"> {count > 0 ? `${count} agents` : 'idle'}</Text>
156
+ </Box>
157
+ );
158
+ })}
159
+ </Box>
160
+ )}
161
+
162
+ {/* Port summary */}
163
+ {portSummary.totalPorts > 0 && (
164
+ <Box marginTop={0}>
165
+ <Text color="blue">{portSummary.totalPorts} ports</Text>
166
+ <Text color="gray"> allocated</Text>
167
+ </Box>
168
+ )}
169
+ </Box>
170
+ </Box>
171
+
172
+ {/* Vertical separator — fill available height */}
173
+ <Box flexDirection="column" marginX={0}>
174
+ <Text color="gray">{'\u2502\n'.repeat(Math.max(5, termHeight - 6))}</Text>
175
+ </Box>
176
+
177
+ {/* Right: Agent Detail + Stream */}
178
+ <Box flexGrow={1} flexDirection="column" overflow="hidden">
179
+ {/* Agent Resource Header — shown when a role is selected */}
180
+ {selectedRoleId && selectedSession && (
181
+ <Box flexDirection="column" paddingX={1} marginBottom={0}>
182
+ <Box justifyContent="space-between">
183
+ <Text bold color="cyan">{selectedRoleId}</Text>
184
+ <Text color={selectedSession.status === 'active' ? 'green' : 'gray'}>
185
+ {selectedSession.status === 'active' ? '\u25CF' : '\u25CB'} {selectedSession.status}
186
+ {selectedSession.startedAt ? ` (${elapsed(selectedSession.startedAt)})` : ''}
187
+ </Text>
188
+ </Box>
189
+
190
+ {/* Ports */}
191
+ <Box>
192
+ <Text color="gray">Port </Text>
193
+ <Text color="white">
194
+ API:{selectedSession.ports.api} Vite:{selectedSession.ports.vite}
195
+ {selectedSession.ports.hmr ? ` HMR:${selectedSession.ports.hmr}` : ''}
196
+ </Text>
197
+ </Box>
198
+
199
+ {/* Worktree */}
200
+ {selectedSession.worktreePath && (
201
+ <Box>
202
+ <Text color="gray">Tree </Text>
203
+ <Text color="white">{selectedSession.worktreePath}</Text>
204
+ </Box>
205
+ )}
206
+
207
+ {/* Wave association */}
208
+ {selectedSession.waveId && (
209
+ <Box>
210
+ <Text color="gray">Wave </Text>
211
+ <Text color="white">
212
+ {(() => {
213
+ const wi = waves.findIndex(w => w.waveId === selectedSession.waveId);
214
+ return wi >= 0 ? `Wave ${wi + 1}` : selectedSession.waveId;
215
+ })()}
216
+ </Text>
217
+ </Box>
218
+ )}
219
+
220
+ {/* Task */}
221
+ {selectedSession.task && (
222
+ <Box>
223
+ <Text color="gray">Task </Text>
224
+ <Text color="white">{selectedSession.task.slice(0, 60)}</Text>
225
+ </Box>
226
+ )}
227
+
228
+ <Text color="gray">{'\u2500'.repeat(40)}</Text>
229
+ </Box>
230
+ )}
231
+
232
+ {/* Agent Resource Header — role selected but no active session */}
233
+ {selectedRoleId && !selectedSession && (
234
+ <Box flexDirection="column" paddingX={1} marginBottom={0}>
235
+ <Text bold color="cyan">{selectedRoleId}</Text>
236
+ <Text color="gray">(no active session)</Text>
237
+ <Text color="gray">{'\u2500'.repeat(40)}</Text>
238
+ </Box>
239
+ )}
240
+
241
+ {/* Stream */}
242
+ <StreamView
243
+ events={roleEvents}
244
+ allRoleIds={flatRoles}
245
+ streamStatus={streamStatus}
246
+ waveId={waveId}
247
+ roleLabel={roleLabel}
248
+ />
249
+ </Box>
250
+ </Box>
251
+
252
+ {/* Separator */}
253
+ <Box width="100%">
254
+ <Text color="gray">{'\u2500'.repeat(process.stdout.columns || 70)}</Text>
255
+ </Box>
256
+
257
+ {/* Footer hints */}
258
+ <Box paddingX={1} justifyContent="center">
259
+ <Text color="gray" dimColor>
260
+ [j/k] move [Enter] select [Esc] back to command
261
+ </Text>
262
+ </Box>
263
+ </Box>
264
+ );
265
+ };
@@ -76,11 +76,18 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
76
76
  };
77
77
 
78
78
  const handleCodeDirSubmit = async (value: string) => {
79
- const dir = value.trim() || './code';
79
+ const path = await import('node:path');
80
+ const fs = await import('node:fs');
81
+ const dir = path.resolve(value.trim() || './code');
80
82
  setCodeDir(dir);
81
83
  setStep('creating');
82
84
 
83
85
  try {
86
+ // Ensure code directory exists
87
+ if (!fs.existsSync(dir)) {
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ }
90
+
84
91
  const result = await postSetupScaffold(companyName, selectedTeam?.id ?? 'minimal');
85
92
  setResultPath(result.path ?? '');
86
93
  setResultRoles(result.rolesCreated ?? 0);
@@ -104,7 +111,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
104
111
  paddingY={1}
105
112
  width={56}
106
113
  >
107
- <Text bold color="cyan">{'\u2500\u2500\u2500'} TYCONO Setup {'\u2500\u2500\u2500'}</Text>
114
+ <Text bold color="cyan">{'───'} TYCONO Setup {'───'}</Text>
108
115
 
109
116
  <Box marginTop={1}>
110
117
  <Text color="gray">No company found. Let's set one up.</Text>
@@ -137,7 +144,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
137
144
  {teams.length > 0 ? (
138
145
  <SelectInput
139
146
  items={teams.map((t) => ({
140
- label: `${t.id} ${t.description || t.roles.join(', ')}`,
147
+ label: `${t.id} ${t.roles.map(r => typeof r === 'string' ? r : r.name || r.id).join(', ')}`,
141
148
  value: t.id,
142
149
  }))}
143
150
  onSelect={handleTeamSelect}