cohvu 2.21.0 → 3.0.0

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.
Files changed (102) hide show
  1. package/README.md +37 -0
  2. package/cli.js +15501 -0
  3. package/package.json +15 -26
  4. package/dist/__tests__/instruction-file.unit.test.d.ts +0 -1
  5. package/dist/__tests__/instruction-file.unit.test.js +0 -296
  6. package/dist/__tests__/instruction-file.unit.test.js.map +0 -1
  7. package/dist/__tests__/teardown.unit.test.d.ts +0 -1
  8. package/dist/__tests__/teardown.unit.test.js +0 -196
  9. package/dist/__tests__/teardown.unit.test.js.map +0 -1
  10. package/dist/api.d.ts +0 -292
  11. package/dist/api.js +0 -337
  12. package/dist/api.js.map +0 -1
  13. package/dist/auth.d.ts +0 -4
  14. package/dist/auth.js +0 -96
  15. package/dist/auth.js.map +0 -1
  16. package/dist/constants.d.ts +0 -2
  17. package/dist/constants.js +0 -10
  18. package/dist/constants.js.map +0 -1
  19. package/dist/index.d.ts +0 -2
  20. package/dist/index.js +0 -259
  21. package/dist/index.js.map +0 -1
  22. package/dist/instructions.d.ts +0 -33
  23. package/dist/instructions.js +0 -238
  24. package/dist/instructions.js.map +0 -1
  25. package/dist/log.d.ts +0 -6
  26. package/dist/log.js +0 -51
  27. package/dist/log.js.map +0 -1
  28. package/dist/pause.d.ts +0 -3
  29. package/dist/pause.js +0 -24
  30. package/dist/pause.js.map +0 -1
  31. package/dist/platforms.d.ts +0 -22
  32. package/dist/platforms.js +0 -79
  33. package/dist/platforms.js.map +0 -1
  34. package/dist/postinstall.d.ts +0 -2
  35. package/dist/postinstall.js +0 -11
  36. package/dist/postinstall.js.map +0 -1
  37. package/dist/proxy.d.ts +0 -1
  38. package/dist/proxy.js +0 -371
  39. package/dist/proxy.js.map +0 -1
  40. package/dist/setup.d.ts +0 -15
  41. package/dist/setup.js +0 -295
  42. package/dist/setup.js.map +0 -1
  43. package/dist/teardown.d.ts +0 -15
  44. package/dist/teardown.js +0 -235
  45. package/dist/teardown.js.map +0 -1
  46. package/dist/tui/App.d.ts +0 -1
  47. package/dist/tui/App.js +0 -2245
  48. package/dist/tui/App.js.map +0 -1
  49. package/dist/tui/components/Banner.d.ts +0 -4
  50. package/dist/tui/components/Banner.js +0 -71
  51. package/dist/tui/components/Banner.js.map +0 -1
  52. package/dist/tui/components/Divider.d.ts +0 -1
  53. package/dist/tui/components/Divider.js +0 -8
  54. package/dist/tui/components/Divider.js.map +0 -1
  55. package/dist/tui/components/Footer.d.ts +0 -4
  56. package/dist/tui/components/Footer.js +0 -183
  57. package/dist/tui/components/Footer.js.map +0 -1
  58. package/dist/tui/components/Header.d.ts +0 -4
  59. package/dist/tui/components/Header.js +0 -126
  60. package/dist/tui/components/Header.js.map +0 -1
  61. package/dist/tui/components/LockCard.d.ts +0 -17
  62. package/dist/tui/components/LockCard.js +0 -37
  63. package/dist/tui/components/LockCard.js.map +0 -1
  64. package/dist/tui/components/Modal.d.ts +0 -5
  65. package/dist/tui/components/Modal.js +0 -195
  66. package/dist/tui/components/Modal.js.map +0 -1
  67. package/dist/tui/components/TabBar.d.ts +0 -4
  68. package/dist/tui/components/TabBar.js +0 -17
  69. package/dist/tui/components/TabBar.js.map +0 -1
  70. package/dist/tui/components/Toast.d.ts +0 -4
  71. package/dist/tui/components/Toast.js +0 -9
  72. package/dist/tui/components/Toast.js.map +0 -1
  73. package/dist/tui/index.d.ts +0 -1
  74. package/dist/tui/index.js +0 -20
  75. package/dist/tui/index.js.map +0 -1
  76. package/dist/tui/platform-detect.d.ts +0 -2
  77. package/dist/tui/platform-detect.js +0 -79
  78. package/dist/tui/platform-detect.js.map +0 -1
  79. package/dist/tui/state.d.ts +0 -414
  80. package/dist/tui/state.js +0 -397
  81. package/dist/tui/state.js.map +0 -1
  82. package/dist/tui/tabs/BillingTab.d.ts +0 -5
  83. package/dist/tui/tabs/BillingTab.js +0 -72
  84. package/dist/tui/tabs/BillingTab.js.map +0 -1
  85. package/dist/tui/tabs/KeysTab.d.ts +0 -5
  86. package/dist/tui/tabs/KeysTab.js +0 -92
  87. package/dist/tui/tabs/KeysTab.js.map +0 -1
  88. package/dist/tui/tabs/KnowledgeTab.d.ts +0 -5
  89. package/dist/tui/tabs/KnowledgeTab.js +0 -143
  90. package/dist/tui/tabs/KnowledgeTab.js.map +0 -1
  91. package/dist/tui/tabs/ProjectTab.d.ts +0 -5
  92. package/dist/tui/tabs/ProjectTab.js +0 -34
  93. package/dist/tui/tabs/ProjectTab.js.map +0 -1
  94. package/dist/tui/tabs/TeamTab.d.ts +0 -5
  95. package/dist/tui/tabs/TeamTab.js +0 -57
  96. package/dist/tui/tabs/TeamTab.js.map +0 -1
  97. package/dist/tui/tabs/YouTab.d.ts +0 -5
  98. package/dist/tui/tabs/YouTab.js +0 -25
  99. package/dist/tui/tabs/YouTab.js.map +0 -1
  100. package/dist/tui/utils.d.ts +0 -6
  101. package/dist/tui/utils.js +0 -58
  102. package/dist/tui/utils.js.map +0 -1
package/dist/tui/App.js DELETED
@@ -1,2245 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // Main TUI component — state, data loading, SSE, key handling, and layout.
3
- // Direct translation of dashboard.ts into React hooks.
4
- import { useReducer, useRef, useEffect, useCallback } from 'react';
5
- import { Box, Text, useInput, useApp, useStdout } from 'ink';
6
- import { reduce, initialState, TABS, getActiveProject, getActiveTeam, isTeamLocked, filterToSameEntity } from './state.js';
7
- import { ApiClient } from '../api.js';
8
- import { runSetup } from '../setup.js';
9
- import { detectPlatformStatuses } from './platform-detect.js';
10
- import { Header } from './components/Header.js';
11
- import { TabBar } from './components/TabBar.js';
12
- import { Banner } from './components/Banner.js';
13
- import { Footer } from './components/Footer.js';
14
- import { Toast } from './components/Toast.js';
15
- import { Divider } from './components/Divider.js';
16
- import { ModalView } from './components/Modal.js';
17
- import { KnowledgeTab } from './tabs/KnowledgeTab.js';
18
- import { TeamTab } from './tabs/TeamTab.js';
19
- import { BillingTab } from './tabs/BillingTab.js';
20
- import { ProjectTab } from './tabs/ProjectTab.js';
21
- import { KeysTab } from './tabs/KeysTab.js';
22
- import { YouTab } from './tabs/YouTab.js';
23
- import { ALL_KEY_ACTIONS } from './state.js';
24
- import { timeUntil } from './utils.js';
25
- import { exec, execFile } from 'child_process';
26
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, watch } from 'fs';
27
- import { join } from 'path';
28
- import { homedir } from 'os';
29
- const STATE_FILE = join(homedir(), '.cohvu', 'state.json');
30
- const PAGE_SIZE = 20;
31
- export default function App() {
32
- const { exit } = useApp();
33
- const { stdout } = useStdout();
34
- const rows = stdout.rows;
35
- const cols = stdout.columns;
36
- // ---- State ----
37
- const [state, rawDispatch] = useReducer(reduce, undefined, initialState);
38
- const stateRef = useRef(state);
39
- stateRef.current = state;
40
- const api = useRef(new ApiClient()).current;
41
- // Timers
42
- const toastTimerRef = useRef(null);
43
- const liveDotTimerRef = useRef(null);
44
- const copiedTimerRef = useRef(null);
45
- const inlineErrorTimerRef = useRef(null);
46
- const bannerTimerRef = useRef(null);
47
- const feedDisconnectRef = useRef(null);
48
- const pollIntervalRef = useRef(null);
49
- const watchersRef = useRef([]);
50
- const lastSyncErrorAt = useRef(0);
51
- const busyRef = useRef(false);
52
- const dispatch = rawDispatch;
53
- // ---- Helpers ----
54
- const showToast = useCallback((message, type, durationMs = 3000) => {
55
- if (toastTimerRef.current)
56
- clearTimeout(toastTimerRef.current);
57
- dispatch({ type: 'SET_TOAST', toast: { message, type, expiresAt: Date.now() + durationMs } });
58
- toastTimerRef.current = setTimeout(() => {
59
- dispatch({ type: 'SET_TOAST', toast: null });
60
- }, durationMs);
61
- }, [dispatch]);
62
- const showInlineError = useCallback((message, durationMs) => {
63
- dispatch({ type: 'SET_INLINE_ERROR', message });
64
- if (inlineErrorTimerRef.current)
65
- clearTimeout(inlineErrorTimerRef.current);
66
- inlineErrorTimerRef.current = setTimeout(() => {
67
- dispatch({ type: 'SET_INLINE_ERROR', message: null });
68
- }, durationMs);
69
- }, [dispatch]);
70
- function openBrowser(url) {
71
- if (process.platform === 'win32') {
72
- exec(`start "" "${url}"`);
73
- }
74
- else {
75
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
76
- execFile(cmd, [url], () => { });
77
- }
78
- }
79
- function copyToClipboard(text) {
80
- const cmd = process.platform === 'darwin' ? 'pbcopy'
81
- : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard';
82
- const child = exec(cmd);
83
- child.stdin?.write(text);
84
- child.stdin?.end();
85
- }
86
- // ---- SSE feed ----
87
- const connectFeed = useCallback((projectId) => {
88
- if (feedDisconnectRef.current)
89
- feedDisconnectRef.current();
90
- const conn = api.connectFeed(projectId, {
91
- onEvent: (eventType, data) => {
92
- // Guard: ignore events if we've since switched to a different project
93
- if (stateRef.current.activeProjectId !== projectId)
94
- return;
95
- if (eventType === 'contribution') {
96
- const event = data;
97
- if (event.operation === 'create') {
98
- dispatch({ type: 'ADD_CONTRIBUTION', contribution: { id: event.id, body: event.body, updated_at: event.updated_at, contributed_by: event.contributed_by, contribution_type: event.contribution_type } });
99
- dispatch({ type: 'SET_LIVE_DOT', contributionId: event.id });
100
- if (liveDotTimerRef.current)
101
- clearTimeout(liveDotTimerRef.current);
102
- liveDotTimerRef.current = setTimeout(() => dispatch({ type: 'CLEAR_LIVE_DOT' }), 10000);
103
- }
104
- else if (event.operation === 'update') {
105
- dispatch({ type: 'UPDATE_CONTRIBUTION', contribution: { id: event.id, body: event.body, updated_at: event.updated_at } });
106
- dispatch({ type: 'SET_LIVE_DOT', contributionId: event.id });
107
- if (liveDotTimerRef.current)
108
- clearTimeout(liveDotTimerRef.current);
109
- liveDotTimerRef.current = setTimeout(() => dispatch({ type: 'CLEAR_LIVE_DOT' }), 10000);
110
- }
111
- else if (event.operation === 'delete') {
112
- dispatch({ type: 'REMOVE_CONTRIBUTION', id: event.id });
113
- }
114
- }
115
- else if (eventType === 'role_change') {
116
- api.me().then(me => dispatch({ type: 'SET_USER_DATA', me })).catch(() => {
117
- if (Date.now() - lastSyncErrorAt.current > 30000) {
118
- lastSyncErrorAt.current = Date.now();
119
- showToast('Sync error', 'error');
120
- }
121
- });
122
- }
123
- else if (eventType === 'notification') {
124
- api.listNotifications().then(notifications => {
125
- dispatch({ type: 'SET_NOTIFICATIONS', notifications });
126
- api.markNotificationsSeen().catch(() => { });
127
- // Clear banners after a few seconds so they don't persist
128
- setTimeout(() => dispatch({ type: 'SET_NOTIFICATIONS', notifications: [] }), 5000);
129
- }).catch(() => {
130
- if (Date.now() - lastSyncErrorAt.current > 30000) {
131
- lastSyncErrorAt.current = Date.now();
132
- showToast('Sync error', 'error');
133
- }
134
- });
135
- }
136
- else if (eventType === 'usage') {
137
- const usage = data;
138
- dispatch({ type: 'SET_USAGE', ops_used: usage.ops_used, ops_limit: usage.ops_limit });
139
- }
140
- else if (eventType === 'approval') {
141
- const team = getActiveTeam(stateRef.current);
142
- if (team) {
143
- api.listApprovals(team.team_id).then(approvals => {
144
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
145
- }).catch(() => { });
146
- }
147
- }
148
- },
149
- onConnected: () => {
150
- dispatch({ type: 'SET_SSE_CONNECTED', connected: true });
151
- // Refresh contributions on reconnection — guard against stale project
152
- if (stateRef.current.activeProjectId === projectId) {
153
- api.listContributions(projectId, { limit: PAGE_SIZE, offset: 0 }).then(result => {
154
- if (result && stateRef.current.activeProjectId === projectId) {
155
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: result.contributions, total: result.total });
156
- }
157
- }).catch(() => { });
158
- }
159
- },
160
- onDisconnected: () => {
161
- dispatch({ type: 'SET_SSE_CONNECTED', connected: false });
162
- },
163
- });
164
- feedDisconnectRef.current = conn.disconnect;
165
- }, [api, dispatch, showToast]);
166
- // ---- Data loading ----
167
- const loadTabData = useCallback(async () => {
168
- const s = stateRef.current;
169
- const projectId = s.activeProjectId;
170
- if (!projectId)
171
- return;
172
- const activeProject = getActiveProject(s);
173
- const activeTeam = getActiveTeam(s);
174
- const isTeamProject = activeProject?.owner.kind === 'team';
175
- try {
176
- switch (s.tab) {
177
- case 'knowledge': {
178
- dispatch({ type: 'SET_LOADING', loading: true });
179
- const result = await api.listContributions(projectId, { limit: PAGE_SIZE, offset: 0 });
180
- // Guard: project may have changed during async load
181
- if (stateRef.current.activeProjectId !== projectId)
182
- break;
183
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: result.contributions, total: result.total });
184
- break;
185
- }
186
- case 'team': {
187
- if (isTeamProject && activeTeam) {
188
- const [members, links] = await Promise.all([
189
- api.listTeamMembers(activeTeam.team_id),
190
- api.listTeamInviteLinks(activeTeam.team_id).catch(() => []),
191
- ]);
192
- if (stateRef.current.activeProjectId !== projectId)
193
- break;
194
- dispatch({ type: 'SET_MEMBERS', members });
195
- dispatch({ type: 'SET_INVITE_LINKS', links });
196
- const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
197
- if (stateRef.current.activeProjectId !== projectId)
198
- break;
199
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
200
- }
201
- else {
202
- dispatch({ type: 'SET_MEMBERS', members: [] });
203
- dispatch({ type: 'SET_INVITE_LINKS', links: [] });
204
- }
205
- break;
206
- }
207
- case 'billing': {
208
- if (isTeamProject && activeTeam) {
209
- const billing = await api.getTeamBilling(activeTeam.team_id);
210
- if (stateRef.current.activeProjectId !== projectId)
211
- break;
212
- dispatch({ type: 'SET_BILLING', billing });
213
- }
214
- else {
215
- const billing = await api.getIndividualBilling();
216
- if (stateRef.current.activeProjectId !== projectId)
217
- break;
218
- dispatch({ type: 'SET_BILLING', billing });
219
- }
220
- break;
221
- }
222
- case 'project':
223
- break;
224
- case 'keys': {
225
- dispatch({ type: 'SET_API_KEYS_LOADING', loading: true });
226
- // Integration keys require admin on the active project — skip the
227
- // call if not, since the API will 403 anyway.
228
- const integrationKeys = (s.userRole === 'admin')
229
- ? await api.listIntegrationKeys(projectId).catch(() => [])
230
- : [];
231
- if (stateRef.current.activeProjectId !== projectId)
232
- break;
233
- dispatch({ type: 'SET_API_KEYS', keys: integrationKeys });
234
- break;
235
- }
236
- }
237
- }
238
- catch {
239
- showToast('Failed to load data', 'error');
240
- }
241
- }, [api, dispatch, showToast]);
242
- const loadMoreMemories = useCallback(async () => {
243
- const s = stateRef.current;
244
- const projectId = s.activeProjectId;
245
- if (!projectId || !s.contributionHasMore)
246
- return;
247
- dispatch({ type: 'SET_LOADING', loading: true });
248
- try {
249
- const result = await api.listContributions(projectId, { limit: PAGE_SIZE, offset: s.contributions.length });
250
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: result.contributions, total: result.total, append: true });
251
- }
252
- catch {
253
- showToast('Failed to load more', 'error');
254
- dispatch({ type: 'SET_LOADING', loading: false });
255
- }
256
- }, [api, dispatch, showToast]);
257
- // ---- Project switching (single source of truth) ----
258
- // Every flow that changes the active project calls this.
259
- const switchToProject = useCallback((projectId) => {
260
- if (pollIntervalRef.current)
261
- clearInterval(pollIntervalRef.current);
262
- connectFeed(projectId);
263
- pollIntervalRef.current = setInterval(async () => {
264
- const ps = stateRef.current;
265
- if (ps.tab !== 'knowledge' || ps.activeProjectId !== projectId)
266
- return;
267
- try {
268
- const result = await api.listContributions(projectId, { limit: PAGE_SIZE, offset: 0 });
269
- if (result && stateRef.current.activeProjectId === projectId && result.total !== stateRef.current.contributionTotal) {
270
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: result.contributions, total: result.total });
271
- }
272
- }
273
- catch { }
274
- }, 30_000);
275
- // Team overview powers the lock card when a team sub is canceled. It's
276
- // cheap and always reflects the current team's shape — fetch on every
277
- // project switch into a team project, clear when switching to personal.
278
- const project = stateRef.current.projects.find(p => p.project_id === projectId);
279
- if (project?.owner.kind === 'team') {
280
- const teamId = project.owner.teamId;
281
- api.getTeamOverview(teamId)
282
- .then(overview => {
283
- if (stateRef.current.activeProjectId === projectId) {
284
- dispatch({ type: 'SET_TEAM_OVERVIEW', overview });
285
- }
286
- })
287
- .catch(() => { });
288
- }
289
- else {
290
- dispatch({ type: 'SET_TEAM_OVERVIEW', overview: null });
291
- }
292
- setTimeout(() => loadTabData(), 0);
293
- }, [api, connectFeed, dispatch, loadTabData]);
294
- const executeSearch = useCallback(async () => {
295
- const s = stateRef.current;
296
- const projectId = s.activeProjectId;
297
- if (!projectId || s.searchQuery.length === 0)
298
- return;
299
- dispatch({ type: 'SET_SEARCHING', searching: true });
300
- try {
301
- const result = await api.searchContributions(projectId, s.searchQuery);
302
- dispatch({ type: 'SET_SEARCH_RESULTS', results: result.contributions });
303
- }
304
- catch {
305
- showToast('Search failed', 'error');
306
- }
307
- dispatch({ type: 'SET_SEARCHING', searching: false });
308
- }, [api, dispatch, showToast]);
309
- // ---- Initial data load ----
310
- useEffect(() => {
311
- let cancelled = false;
312
- async function load() {
313
- // First-login state
314
- const saved = loadPersistedState();
315
- if (!saved.hasOpenedDashboard) {
316
- dispatch({ type: 'SET_FIRST_LOGIN', firstLogin: true });
317
- bannerTimerRef.current = setTimeout(() => {
318
- dispatch({ type: 'SET_FIRST_LOGIN', firstLogin: false });
319
- }, 5000);
320
- }
321
- try {
322
- const me = await api.me();
323
- if (cancelled)
324
- return;
325
- dispatch({ type: 'SET_USER_DATA', me });
326
- // Locked-mode short-circuit. If the account is pending deletion
327
- // (user hit `d` earlier, possibly from another machine or before
328
- // reinstalling), skip all the data-loading fan-out — the server
329
- // would reject those calls anyway. Force the You tab so the
330
- // countdown + cancel flow is immediately visible; everything else
331
- // stays empty until the user either presses `c` or the purge runs.
332
- if (me.user.deletion_scheduled_at) {
333
- dispatch({ type: 'SWITCH_TAB', tab: 'you' });
334
- const platforms = detectPlatformStatuses();
335
- dispatch({ type: 'SET_PLATFORMS', platforms });
336
- return;
337
- }
338
- // Detect platforms without re-running setup (setup already ran in enterDashboard)
339
- const platforms = detectPlatformStatuses();
340
- dispatch({ type: 'SET_PLATFORMS', platforms });
341
- // Load paused state
342
- const { isPaused } = await import('../pause.js');
343
- dispatch({ type: 'SET_PAUSED', paused: isPaused() });
344
- const flatProjects = deriveFlatProjectsFromMe(me);
345
- const activeProject = flatProjects.find(p => p.project_id === me.user.active_project_id) ?? flatProjects[0] ?? null;
346
- const projectId = activeProject?.project_id ?? null;
347
- const isTeamProject = activeProject?.owner.kind === 'team';
348
- const activeTeamId = isTeamProject && activeProject.owner.kind === 'team' ? activeProject.owner.teamId : null;
349
- const activeTeam = activeTeamId ? me.teams.find(t => t.team_id === activeTeamId) : null;
350
- if (projectId) {
351
- // Initial-load failures are usually transient (backend warming up,
352
- // SSE not yet connected, auxiliary endpoints slow). The UI renders
353
- // empty states gracefully for any piece that's null, and the SSE
354
- // onConnected handler re-fetches contributions once the stream is
355
- // live. No toast needed here — showing "Some data failed to load"
356
- // on first launch creates anxiety for a condition that resolves
357
- // itself within seconds.
358
- const [memResult, members, billing, inviteLinks, notifications] = await Promise.all([
359
- api.listContributions(projectId, { limit: PAGE_SIZE, offset: 0 }).catch(() => null),
360
- isTeamProject && activeTeam
361
- ? api.listTeamMembers(activeTeam.team_id).catch(() => [])
362
- : Promise.resolve([]),
363
- isTeamProject && activeTeam
364
- ? api.getTeamBilling(activeTeam.team_id).catch(() => null)
365
- : api.getIndividualBilling().catch(() => null),
366
- isTeamProject && activeTeam
367
- ? api.listTeamInviteLinks(activeTeam.team_id).catch(() => [])
368
- : Promise.resolve([]),
369
- api.listNotifications().catch(() => []),
370
- ]);
371
- if (cancelled)
372
- return;
373
- if (memResult)
374
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: memResult.contributions, total: memResult.total });
375
- dispatch({ type: 'SET_MEMBERS', members });
376
- if (billing)
377
- dispatch({ type: 'SET_BILLING', billing });
378
- dispatch({ type: 'SET_INVITE_LINKS', links: inviteLinks });
379
- dispatch({ type: 'SET_NOTIFICATIONS', notifications });
380
- if (isTeamProject && activeTeam) {
381
- dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: activeTeam.require_consensus ?? false });
382
- const ssoConfig = await api.getSso(activeTeam.team_id).catch(() => null);
383
- if (ssoConfig)
384
- dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
385
- }
386
- if (isTeamProject && activeTeam) {
387
- const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
388
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
389
- }
390
- if (notifications.length > 0) {
391
- api.markNotificationsSeen().catch(() => { });
392
- setTimeout(() => dispatch({ type: 'SET_NOTIFICATIONS', notifications: [] }), 5000);
393
- }
394
- connectFeed(projectId);
395
- // Background poll
396
- pollIntervalRef.current = setInterval(async () => {
397
- const s = stateRef.current;
398
- if (s.tab !== 'knowledge' || s.activeProjectId !== projectId)
399
- return;
400
- try {
401
- const result = await api.listContributions(projectId, { limit: PAGE_SIZE, offset: 0 });
402
- if (result && stateRef.current.activeProjectId === projectId && result.total !== stateRef.current.contributionTotal) {
403
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: result.contributions, total: result.total });
404
- }
405
- }
406
- catch { }
407
- }, 30_000);
408
- }
409
- if (!saved.hasOpenedDashboard) {
410
- savePersistedState({ hasOpenedDashboard: true });
411
- }
412
- }
413
- catch {
414
- if (!cancelled) {
415
- dispatch({ type: 'SET_OFFLINE', offline: true });
416
- dispatch({ type: 'SET_ERROR', error: "can't reach cohvu" });
417
- }
418
- }
419
- }
420
- load();
421
- return () => {
422
- cancelled = true;
423
- if (feedDisconnectRef.current)
424
- feedDisconnectRef.current();
425
- if (pollIntervalRef.current)
426
- clearInterval(pollIntervalRef.current);
427
- };
428
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
429
- // ---- File watchers ----
430
- useEffect(() => {
431
- const configPaths = [
432
- join(homedir(), '.claude.json'),
433
- join(homedir(), '.cursor', 'mcp.json'),
434
- ];
435
- for (const configPath of configPaths) {
436
- if (existsSync(configPath)) {
437
- try {
438
- const w = watch(configPath, () => {
439
- const platforms = detectPlatformStatuses();
440
- dispatch({ type: 'SET_PLATFORMS', platforms });
441
- });
442
- watchersRef.current.push(w);
443
- }
444
- catch { }
445
- }
446
- }
447
- return () => {
448
- watchersRef.current.forEach(w => w.close());
449
- watchersRef.current = [];
450
- };
451
- }, [dispatch]);
452
- // ---- Cleanup timers on unmount ----
453
- useEffect(() => {
454
- return () => {
455
- if (toastTimerRef.current)
456
- clearTimeout(toastTimerRef.current);
457
- if (liveDotTimerRef.current)
458
- clearTimeout(liveDotTimerRef.current);
459
- if (copiedTimerRef.current)
460
- clearTimeout(copiedTimerRef.current);
461
- if (inlineErrorTimerRef.current)
462
- clearTimeout(inlineErrorTimerRef.current);
463
- if (bannerTimerRef.current)
464
- clearTimeout(bannerTimerRef.current);
465
- };
466
- }, []);
467
- // ---- Key handling ----
468
- useInput((input, key) => {
469
- void handleKey(input, key);
470
- });
471
- async function handleKey(input, key) {
472
- const s = stateRef.current;
473
- // Dismiss informational banners on any keypress
474
- if (s.notifications.some(n => !n.seen)) {
475
- dispatch({ type: 'DISMISS_NOTIFICATION_BANNERS' });
476
- }
477
- if (s.firstLogin) {
478
- dispatch({ type: 'SET_FIRST_LOGIN', firstLogin: false });
479
- }
480
- if (s.joinedProjectName) {
481
- dispatch({ type: 'SET_JOINED_PROJECT', name: null });
482
- }
483
- // Global: ctrl+c always exits
484
- if (input === 'c' && key.ctrl) {
485
- exit();
486
- return;
487
- }
488
- // Global: q exits (unless modal open, help open, or in knowledge search/remove/detail mode)
489
- if (input === 'q' && !s.modal && !s.showHelp && !(s.tab === 'knowledge' && s.knowledgeMode !== 'browse')) {
490
- exit();
491
- return;
492
- }
493
- // Global: ? toggles help, escape/q closes it
494
- if (input === '?' && !s.modal && !(s.tab === 'knowledge' && s.knowledgeMode === 'search')) {
495
- dispatch({ type: 'SET_SHOW_HELP', show: !s.showHelp });
496
- return;
497
- }
498
- if (s.showHelp) {
499
- if (key.escape || input === 'q' || input === '?') {
500
- dispatch({ type: 'SET_SHOW_HELP', show: false });
501
- }
502
- return;
503
- }
504
- // Prevent re-entrant async operations (double-press protection)
505
- if (busyRef.current && (key.return || input === 'y'))
506
- return;
507
- // Modal keys
508
- if (s.modal) {
509
- await handleModalKey(input, key);
510
- return;
511
- }
512
- // Team sub canceled — admins can hit `r` from any team tab to start
513
- // resubscribe checkout. Members just see the contact list in the lock
514
- // card; `r` does nothing for them.
515
- if (isTeamLocked(s) && input === 'r' && s.userRole === 'admin') {
516
- const team = getActiveTeam(s);
517
- if (team) {
518
- try {
519
- showToast('Opening checkout...', 'info');
520
- const c = await api.createTeamCheckout(team.team_id);
521
- if (c.checkout_url)
522
- openBrowser(c.checkout_url);
523
- }
524
- catch {
525
- showToast('Failed to open checkout', 'error');
526
- }
527
- }
528
- return;
529
- }
530
- // Pro canceled with locked personal project in view — `u` opens Pro
531
- // checkout. One checkout unlocks every personal project at once, no
532
- // per-project choice. Integration keys stay disabled and need manual
533
- // restore after.
534
- if (input === 'u' && !s.modal) {
535
- const project = getActiveProject(s);
536
- if (project?.owner.kind === 'personal' && project.locked) {
537
- try {
538
- showToast('Opening checkout...', 'info');
539
- const c = await api.createIndividualCheckout();
540
- if (c.checkout_url)
541
- openBrowser(c.checkout_url);
542
- }
543
- catch {
544
- showToast('Failed to open checkout', 'error');
545
- }
546
- return;
547
- }
548
- }
549
- // Approval keys (when approvals exist, on team/project tab, team project)
550
- if ((input === 'a' || input === 'x') && !s.modal && s.pendingApprovals.length > 0 && (s.tab === 'team' || s.tab === 'project')) {
551
- const activeProject = getActiveProject(s);
552
- if (activeProject?.owner.kind === 'team') {
553
- const approval = s.pendingApprovals[0];
554
- if (input === 'a') {
555
- dispatch({ type: 'OPEN_MODAL', modal: {
556
- kind: 'approve-action',
557
- approvalId: approval.id,
558
- description: approval.description,
559
- initiator: approval.initiator_email ?? approval.initiator_name ?? 'unknown',
560
- expiresIn: timeUntil(approval.expires_at),
561
- } });
562
- }
563
- else if (input === 'x') {
564
- const team = getActiveTeam(s);
565
- if (team) {
566
- try {
567
- await api.cancelApproval(team.team_id, approval.id);
568
- const approvals = await api.listApprovals(team.team_id);
569
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
570
- showToast('Canceled', 'success');
571
- }
572
- catch {
573
- showToast('Failed to cancel', 'error');
574
- }
575
- }
576
- }
577
- return;
578
- }
579
- }
580
- // Tab switching
581
- if (key.tab) {
582
- dispatch({ type: 'NEXT_TAB' });
583
- setTimeout(() => loadTabData(), 0);
584
- return;
585
- }
586
- // Locked-mode short-circuit: when the account is pending deletion,
587
- // the only meaningful action is `c` (cancel deletion) on the You tab,
588
- // plus `q`/`ctrl-c` to exit. Other tabs would only show empty API
589
- // failures, and billing shortcuts don't apply to a locked account.
590
- const locked = !!s.user?.deletion_scheduled_at;
591
- // Number keys switch tabs (but not when typing in search mode)
592
- const tabIdx = parseInt(input, 10);
593
- if (tabIdx >= 1 && tabIdx <= TABS.length && !key.ctrl && !key.meta && !(s.tab === 'knowledge' && s.knowledgeMode !== 'browse')) {
594
- if (locked)
595
- return;
596
- dispatch({ type: 'SWITCH_TAB', tab: TABS[tabIdx - 1] });
597
- setTimeout(() => loadTabData(), 0);
598
- return;
599
- }
600
- // Global 'b' shortcut — subscribe/billing portal from banner
601
- if (input === 'b' && !locked && s.userRole === 'admin' && s.tab !== 'billing' && !(s.tab === 'knowledge' && s.knowledgeMode !== 'browse')) {
602
- await handleBillingShortcut();
603
- return;
604
- }
605
- // Tab-specific keys
606
- switch (s.tab) {
607
- case 'knowledge':
608
- await handleKnowledgeKey(input, key);
609
- break;
610
- case 'team':
611
- await handleTeamKey(input, key);
612
- break;
613
- case 'billing':
614
- await handleBillingKey(input, key);
615
- break;
616
- case 'project':
617
- await handleProjectKey(input, key);
618
- break;
619
- case 'keys':
620
- await handleKeysKey(input, key);
621
- break;
622
- case 'you':
623
- await handleYouKey(input, key);
624
- break;
625
- }
626
- }
627
- // ---- Keys tab keys ----
628
- async function handleKeysKey(input, key) {
629
- const s = stateRef.current;
630
- if (key.upArrow) {
631
- dispatch({ type: 'SET_API_KEYS_SELECTED', index: Math.max(0, s.apiKeysSelected - 1) });
632
- return;
633
- }
634
- if (key.downArrow) {
635
- const max = Math.max(0, s.apiKeys.length - 1);
636
- dispatch({ type: 'SET_API_KEYS_SELECTED', index: Math.min(max, s.apiKeysSelected + 1) });
637
- return;
638
- }
639
- if (input === 'n' && s.userRole === 'admin') {
640
- if (!hasPaidPlan(s)) {
641
- showToast('Keys require a paid plan', 'error');
642
- return;
643
- }
644
- dispatch({ type: 'OPEN_MODAL', modal: {
645
- kind: 'create-integration-key',
646
- step: 'name',
647
- input: '',
648
- agentName: '',
649
- projectSelectedIdx: 0,
650
- selectedProjectIds: new Set(),
651
- projectScopes: new Map(),
652
- permProjectIdx: 0,
653
- permActionIdx: 0,
654
- globalScope: 'own',
655
- limitsField: 'expiry',
656
- expiresInDays: '',
657
- opsLimit: '',
658
- } });
659
- return;
660
- }
661
- if (input === 'x' && s.apiKeys.length > 0) {
662
- const target = s.apiKeys[s.apiKeysSelected];
663
- if (target) {
664
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-revoke-key', keyId: target.id, keyName: target.name } });
665
- }
666
- return;
667
- }
668
- // Restore a disabled integration key. Only valid when the selected key
669
- // is disabled — otherwise `r` is a no-op here. Server rotates the
670
- // secret and returns it once; TUI shows the "new secret" modal so the
671
- // admin can copy it into their external systems.
672
- if (input === 'r' && s.userRole === 'admin' && s.apiKeys.length > 0) {
673
- const target = s.apiKeys[s.apiKeysSelected];
674
- if (target && target.disabled_at) {
675
- try {
676
- dispatch({ type: 'SET_OPERATION', operation: 'Restoring key' });
677
- const { api_key, key } = await api.restoreApiKey(target.id);
678
- dispatch({ type: 'SET_OPERATION', operation: null });
679
- dispatch({ type: 'OPEN_MODAL', modal: {
680
- kind: 'key-created',
681
- keyId: api_key.id,
682
- keyValue: key,
683
- keyName: api_key.name,
684
- kind2: 'integration',
685
- } });
686
- // Refresh the list so the restored key moves out of the disabled section.
687
- if (s.activeProjectId) {
688
- const keys = await api.listIntegrationKeys(s.activeProjectId);
689
- dispatch({ type: 'SET_API_KEYS', keys });
690
- }
691
- }
692
- catch {
693
- dispatch({ type: 'SET_OPERATION', operation: null });
694
- showToast('Failed to restore key', 'error');
695
- }
696
- }
697
- return;
698
- }
699
- }
700
- // ---- Knowledge keys ----
701
- async function handleKnowledgeKey(input, key) {
702
- const s = stateRef.current;
703
- // alt+d or ∂ enters remove mode
704
- if (((key.meta && input === 'd') || input === '∂') && s.userRole !== 'viewer') {
705
- const list = s.searchResults ?? s.contributions;
706
- if (list.length > 0)
707
- dispatch({ type: 'ENTER_REMOVE' });
708
- return;
709
- }
710
- if (s.knowledgeMode === 'detail') {
711
- if (key.escape || input === 'q') {
712
- dispatch({ type: 'SET_DETAIL_CONTRIBUTION', contributionId: null });
713
- }
714
- return;
715
- }
716
- if (s.knowledgeMode === 'search') {
717
- if (key.escape) {
718
- dispatch({ type: 'EXIT_SEARCH' });
719
- }
720
- else if (key.return && s.searchQuery.length > 0) {
721
- await executeSearch();
722
- }
723
- else if (key.backspace || key.delete) {
724
- dispatch({ type: 'SEARCH_BACKSPACE' });
725
- }
726
- else if (input === ' ') {
727
- dispatch({ type: 'SEARCH_INPUT', char: ' ' });
728
- }
729
- else if (key.upArrow) {
730
- dispatch({ type: 'SCROLL_UP' });
731
- }
732
- else if (key.downArrow) {
733
- dispatch({ type: 'SCROLL_DOWN' });
734
- }
735
- else if (input.length === 1 && !key.ctrl && !key.meta) {
736
- dispatch({ type: 'SEARCH_INPUT', char: input });
737
- }
738
- return;
739
- }
740
- if (s.knowledgeMode === 'remove') {
741
- const removeList = s.searchResults ?? s.contributions;
742
- const filtered = s.userRole === 'admin'
743
- ? removeList
744
- : removeList.filter(m => m.contributed_by?.user_id === s.user?.id);
745
- if (s.removeConfirming && input === 'y') {
746
- const projectId = s.activeProjectId;
747
- if (projectId) {
748
- let failures = 0;
749
- dispatch({ type: 'SET_OPERATION', operation: 'Removing contributions' });
750
- await Promise.all([...s.removeSelected].map(async (id) => {
751
- try {
752
- await api.deleteContribution(projectId, id);
753
- dispatch({ type: 'REMOVE_CONTRIBUTION', id });
754
- }
755
- catch {
756
- failures++;
757
- }
758
- }));
759
- dispatch({ type: 'SET_OPERATION', operation: null });
760
- dispatch({ type: 'EXIT_REMOVE' });
761
- if (failures > 0)
762
- showToast(`${failures} failed to remove`, 'error');
763
- else
764
- showToast('Removed', 'success');
765
- }
766
- }
767
- else if (s.removeConfirming && (key.escape || input === 'n')) {
768
- dispatch({ type: 'SET_REMOVE_CONFIRMING', confirming: false });
769
- }
770
- else if (key.escape) {
771
- dispatch({ type: 'EXIT_REMOVE' });
772
- }
773
- else if (input === ' ') {
774
- const mem = filtered[s.contributionSelected];
775
- if (mem)
776
- dispatch({ type: 'TOGGLE_REMOVE', contributionId: mem.id });
777
- }
778
- else if (key.upArrow) {
779
- if (s.contributionSelected > 0)
780
- dispatch({ type: 'SCROLL_UP' });
781
- }
782
- else if (key.downArrow) {
783
- if (s.contributionSelected < filtered.length - 1)
784
- dispatch({ type: 'SCROLL_DOWN' });
785
- }
786
- else if (key.return && s.removeSelected.size > 0 && !s.removeConfirming) {
787
- dispatch({ type: 'SET_REMOVE_CONFIRMING', confirming: true });
788
- }
789
- return;
790
- }
791
- // Browse mode
792
- if (input === '/') {
793
- dispatch({ type: 'ENTER_SEARCH' });
794
- }
795
- else if (input === 'd' && s.userRole !== 'viewer' && s.contributions.length > 0) {
796
- dispatch({ type: 'ENTER_REMOVE' });
797
- }
798
- else if (input === 'D' && s.userRole === 'admin') {
799
- const project = getActiveProject(s);
800
- if (project)
801
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-remove-all', slug: project.slug, contributionCount: s.contributionTotal, input: '' } });
802
- }
803
- else if (key.return && s.contributions.length > 0) {
804
- const mem = s.contributions[s.contributionSelected];
805
- if (mem)
806
- dispatch({ type: 'SET_DETAIL_CONTRIBUTION', contributionId: mem.id });
807
- }
808
- else if (key.upArrow) {
809
- dispatch({ type: 'SCROLL_UP' });
810
- }
811
- else if (key.downArrow) {
812
- dispatch({ type: 'SCROLL_DOWN' });
813
- }
814
- else if (input === ' ') {
815
- await loadMoreMemories();
816
- }
817
- }
818
- // ---- Team keys ----
819
- async function handleTeamKey(input, key) {
820
- const s = stateRef.current;
821
- const activeProject = getActiveProject(s);
822
- if (!activeProject || activeProject.owner.kind === 'personal')
823
- return;
824
- const team = getActiveTeam(s);
825
- if (!team)
826
- return;
827
- const memberCount = s.members.length;
828
- const linkRoles = ['admin', 'member', 'viewer'];
829
- // Settings rows (admin only): name, consensus, sso, then 3 invite link rows
830
- const settingsCount = s.userRole === 'admin' ? 6 : 0; // 3 settings + 3 links
831
- const totalRows = memberCount + settingsCount;
832
- const sel = s.teamSelected;
833
- const onMemberRow = sel < memberCount;
834
- const onLinkRow = s.userRole === 'admin' && sel >= memberCount + 3;
835
- if (key.upArrow) {
836
- dispatch({ type: 'SET_TEAM_SELECTED', index: Math.max(0, sel - 1) });
837
- return;
838
- }
839
- if (key.downArrow) {
840
- dispatch({ type: 'SET_TEAM_SELECTED', index: Math.min(totalRows - 1, sel + 1) });
841
- return;
842
- }
843
- // 'i' key (admin) — open invite modal
844
- if (input === 'i' && s.userRole === 'admin') {
845
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite', selected: 0 } });
846
- return;
847
- }
848
- // Enter or 'r' key on name row — rename team
849
- if ((key.return || input === 'r') && s.userRole === 'admin' && sel === memberCount + 0) {
850
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-rename-team', input: '' } });
851
- return;
852
- }
853
- // 'r' key on invite link row — regen (member/viewer) or mint-new (admin).
854
- // Admin regen is semantically the same as mint since each admin link is
855
- // single-use; we just mint and surface instead of asking for confirmation.
856
- if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
857
- const linkIdx = sel - memberCount - 3;
858
- const role = linkRoles[linkIdx];
859
- if (role === 'admin' && team) {
860
- try {
861
- const link = await api.mintAdminInvite(team.team_id);
862
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role: 'admin', url: link.url } });
863
- }
864
- catch {
865
- showToast('Failed to mint admin invite', 'error');
866
- }
867
- return;
868
- }
869
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
870
- return;
871
- }
872
- // Enter on consensus row — toggle
873
- if (key.return && s.userRole === 'admin' && sel === memberCount + 1) {
874
- if (team) {
875
- try {
876
- const newValue = !s.requireConsensus;
877
- await api.updateTeamSettings(team.team_id, { require_consensus: newValue });
878
- dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: newValue });
879
- showToast(newValue ? 'Consensus required' : 'Consensus off', 'success');
880
- }
881
- catch {
882
- showToast('Failed to update', 'error');
883
- }
884
- }
885
- return;
886
- }
887
- // Enter or 's' key on SSO row — configure or manage SSO
888
- if ((key.return || input === 's') && s.userRole === 'admin' && sel === memberCount + 2) {
889
- if (s.ssoConfig) {
890
- // SSO already exists — offer edit/delete
891
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'manage-sso' } });
892
- }
893
- else {
894
- dispatch({ type: 'OPEN_MODAL', modal: {
895
- kind: 'configure-sso', step: 1, issuer: '', clientId: '', clientSecret: '', domains: '', defaultRole: 0, requireSso: false,
896
- } });
897
- }
898
- return;
899
- }
900
- // 'c' key on invite link row — copy link. Admin row has no persistent
901
- // URL (admin invites are minted on demand) so 'c' is a no-op there.
902
- if (input === 'c' && s.userRole === 'admin' && onLinkRow) {
903
- const linkIdx = sel - memberCount - 3;
904
- const role = linkRoles[linkIdx];
905
- if (role === 'admin')
906
- return;
907
- const link = s.inviteLinks.find(l => l.role === role);
908
- if (link) {
909
- copyToClipboard(link.url);
910
- dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
911
- if (copiedTimerRef.current)
912
- clearTimeout(copiedTimerRef.current);
913
- copiedTimerRef.current = setTimeout(() => dispatch({ type: 'SET_COPIED_FEEDBACK', active: false }), 1500);
914
- }
915
- return;
916
- }
917
- // 'o' key on invite link row — open in browser. Same as 'c', admin row
918
- // has nothing to open since the link doesn't exist until minted.
919
- if (input === 'o' && s.userRole === 'admin' && onLinkRow) {
920
- const linkIdx = sel - memberCount - 3;
921
- const role = linkRoles[linkIdx];
922
- if (role === 'admin')
923
- return;
924
- const link = s.inviteLinks.find(l => l.role === role);
925
- if (link)
926
- openBrowser(link.url);
927
- return;
928
- }
929
- // Enter on the admin invite row — mint a fresh short-lived single-use
930
- // link and surface it via the invite-link modal (copy/open affordances).
931
- if (key.return && s.userRole === 'admin' && onLinkRow) {
932
- const linkIdx = sel - memberCount - 3;
933
- const role = linkRoles[linkIdx];
934
- if (role === 'admin' && team) {
935
- try {
936
- const link = await api.mintAdminInvite(team.team_id);
937
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role: 'admin', url: link.url } });
938
- }
939
- catch {
940
- showToast('Failed to mint admin invite', 'error');
941
- }
942
- return;
943
- }
944
- }
945
- // 'd' key (admin) — delete team
946
- if (input === 'd' && s.userRole === 'admin') {
947
- if (team) {
948
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-delete-team', slug: team.slug, teamName: team.name, input: '' } });
949
- }
950
- return;
951
- }
952
- // 'e' key on member row — edit role
953
- if (input === 'e' && s.userRole === 'admin' && onMemberRow) {
954
- const target = s.members[sel];
955
- if (target) {
956
- if (target.user_id === s.user?.id) {
957
- showInlineError('you cannot change your own role', 2000);
958
- return;
959
- }
960
- const roleIdx = ['admin', 'member', 'viewer'].indexOf(target.role ?? 'member');
961
- dispatch({ type: 'OPEN_MODAL', modal: {
962
- kind: 'edit-role',
963
- targetEmail: target.email ?? target.user_id,
964
- targetUserId: target.user_id,
965
- currentRole: target.role ?? 'member',
966
- selected: roleIdx >= 0 ? roleIdx : 1,
967
- } });
968
- }
969
- return;
970
- }
971
- // 'x' key — remove member or leave
972
- if (input === 'x') {
973
- if (s.userRole === 'admin' && onMemberRow && team) {
974
- const target = s.members[sel];
975
- if (target) {
976
- if (target.user_id === s.user?.id) {
977
- const adminCount = s.members.filter(m => m.role === 'admin').length;
978
- if (adminCount <= 1) {
979
- showInlineError('you are the only admin · promote another member before leaving', 3000);
980
- return;
981
- }
982
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-leave' } });
983
- }
984
- else {
985
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-remove-member', email: target.email ?? target.user_id, userId: target.user_id } });
986
- }
987
- }
988
- }
989
- else if (s.userRole !== 'admin') {
990
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-leave' } });
991
- }
992
- }
993
- }
994
- // ---- Billing keys ----
995
- async function handleBillingKey(input, _key) {
996
- const s = stateRef.current;
997
- if (s.userRole !== 'admin')
998
- return;
999
- const project = getActiveProject(s);
1000
- if (!project)
1001
- return;
1002
- const isTeam = project.owner.kind === 'team';
1003
- const team = getActiveTeam(s);
1004
- if (input === 's') {
1005
- try {
1006
- showToast('Opening checkout...', 'info');
1007
- if (isTeam && team) {
1008
- const checkout = await api.createTeamCheckout(team.team_id);
1009
- if (checkout.checkout_url)
1010
- openBrowser(checkout.checkout_url);
1011
- }
1012
- else {
1013
- const checkout = await api.createIndividualCheckout();
1014
- if (checkout.checkout_url)
1015
- openBrowser(checkout.checkout_url);
1016
- }
1017
- }
1018
- catch {
1019
- showToast('Failed to open checkout', 'error');
1020
- }
1021
- }
1022
- else if (input === 'p') {
1023
- try {
1024
- showToast('Opening billing portal...', 'info');
1025
- if (isTeam && team) {
1026
- const portal = await api.getTeamPortalUrl(team.team_id);
1027
- if (portal.url)
1028
- openBrowser(portal.url);
1029
- }
1030
- else {
1031
- const portal = await api.getIndividualPortalUrl();
1032
- if (portal.url)
1033
- openBrowser(portal.url);
1034
- }
1035
- }
1036
- catch {
1037
- showToast('Failed to open billing portal', 'error');
1038
- }
1039
- }
1040
- }
1041
- // ---- Project keys ----
1042
- async function handleProjectKey(input, key) {
1043
- const s = stateRef.current;
1044
- if (input === 't') {
1045
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team', input: '' } });
1046
- return;
1047
- }
1048
- if (input === 'r' && s.userRole === 'admin' && s.activeProjectId) {
1049
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'rename', input: '' } });
1050
- }
1051
- else if (input === 'n') {
1052
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'select-owner', selected: 0 } });
1053
- }
1054
- else if (input === 'w' && s.projects.length > 1) {
1055
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: 0 } });
1056
- }
1057
- else if (input === 'c' && s.userRole === 'admin') {
1058
- const project = getActiveProject(s);
1059
- if (project)
1060
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-clear', slug: project.slug, contributionCount: s.contributionTotal, input: '' } });
1061
- }
1062
- else if (input === 'd' && s.userRole === 'admin') {
1063
- const project = getActiveProject(s);
1064
- if (project)
1065
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-delete', slug: project.slug, contributionCount: s.contributionTotal, input: '' } });
1066
- }
1067
- }
1068
- // ---- You keys ----
1069
- async function handleYouKey(input, _key) {
1070
- const s = stateRef.current;
1071
- if (input === 'r') {
1072
- await runSetup();
1073
- const platforms = detectPlatformStatuses();
1074
- dispatch({ type: 'SET_PLATFORMS', platforms });
1075
- }
1076
- else if (input === 'p') {
1077
- const { isPaused, setPaused } = await import('../pause.js');
1078
- const newPaused = !isPaused();
1079
- setPaused(newPaused);
1080
- dispatch({ type: 'SET_PAUSED', paused: newPaused });
1081
- showToast(newPaused ? 'Paused — agents won\'t see tools until resumed' : 'Resumed', 'success');
1082
- }
1083
- else if (input === 'l') {
1084
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-logout' } });
1085
- }
1086
- else if (input === 'd' && !s.user?.deletion_scheduled_at) {
1087
- // Open the delete-account confirmation. Email-typed confirm prevents
1088
- // muscle-memory destruction.
1089
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-account-delete', email: s.user?.email ?? '', input: '' } });
1090
- }
1091
- else if (input === 'c' && s.user?.deletion_scheduled_at) {
1092
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-cancel-account-deletion' } });
1093
- }
1094
- }
1095
- // ---- Billing shortcut (from banner) ----
1096
- async function handleBillingShortcut() {
1097
- const s = stateRef.current;
1098
- const project = getActiveProject(s);
1099
- if (!project)
1100
- return;
1101
- const isTeam = project.owner.kind === 'team';
1102
- const team = getActiveTeam(s);
1103
- if (isTeam && team) {
1104
- const sub = team.subscription;
1105
- if (!sub || sub.status !== 'active') {
1106
- try {
1107
- showToast('Opening checkout...', 'info');
1108
- const c = await api.createTeamCheckout(team.team_id);
1109
- if (c.checkout_url)
1110
- openBrowser(c.checkout_url);
1111
- }
1112
- catch {
1113
- showToast('Failed to open checkout', 'error');
1114
- }
1115
- }
1116
- else {
1117
- try {
1118
- showToast('Opening billing portal...', 'info');
1119
- const p = await api.getTeamPortalUrl(team.team_id);
1120
- if (p.url)
1121
- openBrowser(p.url);
1122
- }
1123
- catch {
1124
- showToast('Failed to open billing portal', 'error');
1125
- }
1126
- }
1127
- }
1128
- else {
1129
- const sub = s.individualSubscription;
1130
- if (!sub || sub.status !== 'active') {
1131
- try {
1132
- showToast('Opening checkout...', 'info');
1133
- const c = await api.createIndividualCheckout();
1134
- if (c.checkout_url)
1135
- openBrowser(c.checkout_url);
1136
- }
1137
- catch {
1138
- showToast('Failed to open checkout', 'error');
1139
- }
1140
- }
1141
- else {
1142
- try {
1143
- showToast('Opening billing portal...', 'info');
1144
- const p = await api.getIndividualPortalUrl();
1145
- if (p.url)
1146
- openBrowser(p.url);
1147
- }
1148
- catch {
1149
- showToast('Failed to open billing portal', 'error');
1150
- }
1151
- }
1152
- }
1153
- }
1154
- // ---- Modal keys ----
1155
- async function handleModalKey(input, key) {
1156
- const s = stateRef.current;
1157
- if (!s.modal)
1158
- return;
1159
- if (key.escape) {
1160
- dispatch({ type: 'CLOSE_MODAL' });
1161
- return;
1162
- }
1163
- const modal = s.modal;
1164
- // y/n modals
1165
- if (modal.kind === 'confirm-remove' || modal.kind === 'confirm-remove-member' ||
1166
- modal.kind === 'confirm-leave' || modal.kind === 'confirm-logout' ||
1167
- modal.kind === 'confirm-regen-link' || modal.kind === 'initiate-consensus' ||
1168
- modal.kind === 'approve-action' || modal.kind === 'confirm-revoke-key' ||
1169
- modal.kind === 'confirm-cancel-account-deletion') {
1170
- if (input === 'y') {
1171
- const willExit = modal.kind === 'confirm-logout';
1172
- await confirmModal(modal);
1173
- if (!willExit)
1174
- dispatch({ type: 'CLOSE_MODAL' });
1175
- }
1176
- else if (input === 'n') {
1177
- dispatch({ type: 'CLOSE_MODAL' });
1178
- }
1179
- return;
1180
- }
1181
- // Info-only modals — any key dismisses.
1182
- if (modal.kind === 'account-delete-blocked' || modal.kind === 'account-delete-scheduled') {
1183
- dispatch({ type: 'CLOSE_MODAL' });
1184
- return;
1185
- }
1186
- // Type-email-to-confirm: account delete.
1187
- if (modal.kind === 'confirm-account-delete') {
1188
- if (key.return && modal.input === modal.email) {
1189
- await confirmModal(modal);
1190
- return;
1191
- }
1192
- if (key.backspace || key.delete) {
1193
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input.slice(0, -1) } });
1194
- }
1195
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1196
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input + input } });
1197
- }
1198
- return;
1199
- }
1200
- // Integration key creation wizard
1201
- if (modal.kind === 'create-integration-key') {
1202
- // Silo rule: once one project is selected, only same-entity projects
1203
- // are reachable. Filter here so index arithmetic in the picker stays
1204
- // consistent with what the Modal renders.
1205
- const adminProjects = filterToSameEntity(adminScopableProjects(s), modal.selectedProjectIds, s.user?.id ?? null);
1206
- if (modal.step === 'name') {
1207
- if (key.return && modal.input.trim().length > 0) {
1208
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'agent' } });
1209
- }
1210
- else if (key.backspace || key.delete) {
1211
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input.slice(0, -1) } });
1212
- }
1213
- else if (input === ' ') {
1214
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input + ' ' } });
1215
- }
1216
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1217
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input + input } });
1218
- }
1219
- return;
1220
- }
1221
- if (modal.step === 'agent') {
1222
- if (key.return) {
1223
- if (adminProjects.length === 0) {
1224
- showInlineError('no projects you can scope a key to', 3000);
1225
- return;
1226
- }
1227
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'projects' } });
1228
- }
1229
- else if (key.backspace || key.delete) {
1230
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName.slice(0, -1) } });
1231
- }
1232
- else if (input === ' ') {
1233
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + ' ' } });
1234
- }
1235
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1236
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + input } });
1237
- }
1238
- return;
1239
- }
1240
- if (modal.step === 'projects') {
1241
- if (adminProjects.length === 0)
1242
- return;
1243
- if (key.upArrow) {
1244
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: Math.max(0, modal.projectSelectedIdx - 1) } });
1245
- }
1246
- else if (key.downArrow) {
1247
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: Math.min(adminProjects.length - 1, modal.projectSelectedIdx + 1) } });
1248
- }
1249
- else if (input === ' ') {
1250
- const p = adminProjects[modal.projectSelectedIdx];
1251
- if (p) {
1252
- const next = new Set(modal.selectedProjectIds);
1253
- if (next.has(p.project_id))
1254
- next.delete(p.project_id);
1255
- else
1256
- next.add(p.project_id);
1257
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selectedProjectIds: next } });
1258
- }
1259
- }
1260
- else if (key.return && modal.selectedProjectIds.size > 0) {
1261
- // Initialize default scopes for new projects (all actions on)
1262
- const scopes = new Map(modal.projectScopes);
1263
- for (const pid of modal.selectedProjectIds) {
1264
- if (!scopes.has(pid)) {
1265
- scopes.set(pid, new Set([...ALL_KEY_ACTIONS]));
1266
- }
1267
- }
1268
- // Remove scopes for deselected projects
1269
- for (const pid of scopes.keys()) {
1270
- if (!modal.selectedProjectIds.has(pid))
1271
- scopes.delete(pid);
1272
- }
1273
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'permissions', projectScopes: scopes, permProjectIdx: 0, permActionIdx: 0 } });
1274
- }
1275
- return;
1276
- }
1277
- if (modal.step === 'permissions') {
1278
- const selectedProjects = adminProjects.filter(p => modal.selectedProjectIds.has(p.project_id));
1279
- const projectCount = selectedProjects.length;
1280
- if (projectCount === 0)
1281
- return;
1282
- if (key.tab) {
1283
- // tab cycles through projects
1284
- const next = (modal.permProjectIdx + 1) % projectCount;
1285
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: next, permActionIdx: 0 } });
1286
- }
1287
- else if (key.upArrow) {
1288
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: Math.max(0, modal.permProjectIdx - 1), permActionIdx: 0 } });
1289
- }
1290
- else if (key.downArrow) {
1291
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: Math.min(projectCount - 1, modal.permProjectIdx + 1), permActionIdx: 0 } });
1292
- }
1293
- else if (key.leftArrow) {
1294
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permActionIdx: Math.max(0, modal.permActionIdx - 1) } });
1295
- }
1296
- else if (key.rightArrow) {
1297
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permActionIdx: Math.min(ALL_KEY_ACTIONS.length - 1, modal.permActionIdx + 1) } });
1298
- }
1299
- else if (input === ' ') {
1300
- const p = selectedProjects[modal.permProjectIdx];
1301
- if (p) {
1302
- const action = ALL_KEY_ACTIONS[modal.permActionIdx];
1303
- const scopes = new Map(modal.projectScopes);
1304
- const actions = new Set(scopes.get(p.project_id) ?? []);
1305
- if (actions.has(action))
1306
- actions.delete(action);
1307
- else
1308
- actions.add(action);
1309
- scopes.set(p.project_id, actions);
1310
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectScopes: scopes } });
1311
- }
1312
- }
1313
- else if (key.return) {
1314
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'scope' } });
1315
- }
1316
- return;
1317
- }
1318
- if (modal.step === 'scope') {
1319
- if (key.upArrow || key.downArrow) {
1320
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, globalScope: modal.globalScope === 'own' ? 'all' : 'own' } });
1321
- }
1322
- else if (key.return) {
1323
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'limits' } });
1324
- }
1325
- return;
1326
- }
1327
- if (modal.step === 'limits') {
1328
- if (key.return) {
1329
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'confirm' } });
1330
- }
1331
- else if (key.tab) {
1332
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, limitsField: modal.limitsField === 'expiry' ? 'ops' : 'expiry' } });
1333
- }
1334
- else if (key.backspace || key.delete) {
1335
- if (modal.limitsField === 'expiry') {
1336
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, expiresInDays: modal.expiresInDays.slice(0, -1) } });
1337
- }
1338
- else {
1339
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, opsLimit: modal.opsLimit.slice(0, -1) } });
1340
- }
1341
- }
1342
- else if (/^[0-9]$/.test(input)) {
1343
- if (modal.limitsField === 'expiry') {
1344
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, expiresInDays: modal.expiresInDays + input } });
1345
- }
1346
- else {
1347
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, opsLimit: modal.opsLimit + input } });
1348
- }
1349
- }
1350
- return;
1351
- }
1352
- if (modal.step === 'confirm') {
1353
- if (input === 'y') {
1354
- await confirmModal(modal);
1355
- }
1356
- else if (input === 'n') {
1357
- dispatch({ type: 'CLOSE_MODAL' });
1358
- }
1359
- return;
1360
- }
1361
- return;
1362
- }
1363
- // Key created — copy or dismiss (esc handled at top)
1364
- if (modal.kind === 'key-created') {
1365
- if (input === 'c') {
1366
- copyToClipboard(modal.keyValue);
1367
- dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
1368
- if (copiedTimerRef.current)
1369
- clearTimeout(copiedTimerRef.current);
1370
- copiedTimerRef.current = setTimeout(() => dispatch({ type: 'SET_COPIED_FEEDBACK', active: false }), 1500);
1371
- }
1372
- return;
1373
- }
1374
- // Text input modals
1375
- if ('input' in modal) {
1376
- if (key.return) {
1377
- const chained = await confirmModal(modal);
1378
- if (!chained)
1379
- dispatch({ type: 'CLOSE_MODAL' });
1380
- }
1381
- else if (key.backspace || key.delete) {
1382
- dispatch({ type: 'MODAL_BACKSPACE' });
1383
- }
1384
- else if (input === ' ') {
1385
- dispatch({ type: 'MODAL_INPUT', char: ' ' });
1386
- }
1387
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1388
- dispatch({ type: 'MODAL_INPUT', char: input });
1389
- }
1390
- return;
1391
- }
1392
- // Switch project modal
1393
- if (modal.kind === 'switch-project') {
1394
- if (key.upArrow) {
1395
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: Math.max(0, modal.selected - 1) } });
1396
- }
1397
- else if (key.downArrow) {
1398
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: Math.min(s.projects.length, modal.selected + 1) } });
1399
- }
1400
- else if (key.return) {
1401
- if (modal.selected === s.projects.length) {
1402
- dispatch({ type: 'CLOSE_MODAL' });
1403
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'select-owner', selected: 0 } });
1404
- }
1405
- else {
1406
- const project = s.projects[modal.selected];
1407
- if (project) {
1408
- try {
1409
- await api.switchProject(project.project_id);
1410
- const me = await api.me();
1411
- dispatch({ type: 'SET_USER_DATA', me });
1412
- dispatch({ type: 'CLOSE_MODAL' });
1413
- const newProjectId = me.user.active_project_id;
1414
- if (newProjectId)
1415
- switchToProject(newProjectId);
1416
- }
1417
- catch {
1418
- dispatch({ type: 'CLOSE_MODAL' });
1419
- showToast('Failed to switch project', 'error');
1420
- }
1421
- }
1422
- }
1423
- }
1424
- return;
1425
- }
1426
- // Edit role modal
1427
- if (modal.kind === 'edit-role') {
1428
- const roles = ['admin', 'member', 'viewer'];
1429
- if (key.upArrow) {
1430
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
1431
- }
1432
- else if (key.downArrow) {
1433
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(roles.length - 1, modal.selected + 1) } });
1434
- }
1435
- else if (key.return) {
1436
- const newRole = roles[modal.selected];
1437
- if (newRole !== modal.currentRole) {
1438
- if (modal.currentRole === 'admin' && newRole !== 'admin') {
1439
- const adminCount = s.members.filter(m => m.role === 'admin').length;
1440
- if (adminCount <= 1) {
1441
- showInlineError('cannot demote the last admin · promote another member first', 2000);
1442
- dispatch({ type: 'CLOSE_MODAL' });
1443
- return;
1444
- }
1445
- }
1446
- const changeTeam = getActiveTeam(s);
1447
- if (changeTeam) {
1448
- try {
1449
- // Server decides whether consensus applies — if so, it returns
1450
- // 202 with a pending_approval object; otherwise the role change
1451
- // applies immediately. We don't branch client-side.
1452
- const resp = await api.changeTeamRole(changeTeam.team_id, modal.targetUserId, newRole);
1453
- if (resp.pending_approval) {
1454
- const approvals = await api.listApprovals(changeTeam.team_id);
1455
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1456
- showToast('Role change proposed — waiting for majority', 'info');
1457
- }
1458
- else {
1459
- const members = await api.listTeamMembers(changeTeam.team_id);
1460
- dispatch({ type: 'SET_MEMBERS', members });
1461
- showToast('Role updated', 'success');
1462
- }
1463
- }
1464
- catch {
1465
- showToast('Failed to update role', 'error');
1466
- }
1467
- }
1468
- }
1469
- dispatch({ type: 'CLOSE_MODAL' });
1470
- }
1471
- }
1472
- // Select owner modal
1473
- if (modal.kind === 'select-owner') {
1474
- const itemCount = 1 + s.teams.length + 1; // "personal" + each team + "+ new team"
1475
- if (key.upArrow) {
1476
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
1477
- }
1478
- else if (key.downArrow) {
1479
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(itemCount - 1, modal.selected + 1) } });
1480
- }
1481
- else if (key.return) {
1482
- dispatch({ type: 'CLOSE_MODAL' });
1483
- if (modal.selected === 0) {
1484
- // Personal
1485
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
1486
- }
1487
- else if (modal.selected <= s.teams.length) {
1488
- // Existing team
1489
- const team = s.teams[modal.selected - 1];
1490
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: team.team_id } });
1491
- }
1492
- else {
1493
- // + new team → chains to create-team → create-team-project
1494
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team', input: '' } });
1495
- }
1496
- }
1497
- return;
1498
- }
1499
- // Invite modal — select role
1500
- if (modal.kind === 'invite') {
1501
- const roles = ['admin', 'member', 'viewer'];
1502
- if (key.upArrow) {
1503
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
1504
- }
1505
- else if (key.downArrow) {
1506
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(2, modal.selected + 1) } });
1507
- }
1508
- else if (key.return) {
1509
- const role = roles[modal.selected];
1510
- const team = getActiveTeam(s);
1511
- if (!team) {
1512
- dispatch({ type: 'CLOSE_MODAL' });
1513
- showToast('No team selected', 'error');
1514
- return;
1515
- }
1516
- if (role === 'admin') {
1517
- // Admin invites are minted on the fly — short-lived, single-use.
1518
- // No persistent link in state.inviteLinks; each invite is fresh.
1519
- try {
1520
- const link = await api.mintAdminInvite(team.team_id);
1521
- dispatch({ type: 'CLOSE_MODAL' });
1522
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role, url: link.url } });
1523
- }
1524
- catch {
1525
- dispatch({ type: 'CLOSE_MODAL' });
1526
- showToast('Failed to mint admin invite', 'error');
1527
- }
1528
- return;
1529
- }
1530
- const link = s.inviteLinks.find(l => l.role === role);
1531
- if (link) {
1532
- dispatch({ type: 'CLOSE_MODAL' });
1533
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role, url: link.url } });
1534
- }
1535
- else {
1536
- dispatch({ type: 'CLOSE_MODAL' });
1537
- showToast('Invite link not available', 'error');
1538
- }
1539
- }
1540
- return;
1541
- }
1542
- // Invite link modal — copy or open
1543
- if (modal.kind === 'invite-link') {
1544
- if (input === 'c') {
1545
- copyToClipboard(modal.url);
1546
- dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
1547
- if (copiedTimerRef.current)
1548
- clearTimeout(copiedTimerRef.current);
1549
- copiedTimerRef.current = setTimeout(() => dispatch({ type: 'SET_COPIED_FEEDBACK', active: false }), 1500);
1550
- }
1551
- else if (input === 'o') {
1552
- openBrowser(modal.url);
1553
- showToast('Opened in browser', 'info');
1554
- }
1555
- return;
1556
- }
1557
- // Manage existing SSO — edit or delete
1558
- if (modal.kind === 'manage-sso') {
1559
- if (input === 'e') {
1560
- // Edit — open wizard pre-filled with current values
1561
- const sso = s.ssoConfig;
1562
- dispatch({ type: 'CLOSE_MODAL' });
1563
- dispatch({ type: 'OPEN_MODAL', modal: {
1564
- kind: 'configure-sso', step: 1,
1565
- issuer: sso?.issuer ?? '', clientId: '', clientSecret: '',
1566
- domains: sso?.allowed_domains.join(', ') ?? '', defaultRole: ['member', 'viewer', 'admin'].indexOf(sso?.default_role ?? 'member'),
1567
- requireSso: sso?.require_sso ?? false,
1568
- } });
1569
- }
1570
- else if (input === 'd') {
1571
- const team = getActiveTeam(s);
1572
- if (team) {
1573
- try {
1574
- await api.deleteSso(team.team_id);
1575
- dispatch({ type: 'SET_SSO_CONFIG', config: null });
1576
- dispatch({ type: 'CLOSE_MODAL' });
1577
- showToast('SSO removed', 'success');
1578
- }
1579
- catch {
1580
- showToast('Failed to remove SSO', 'error');
1581
- }
1582
- }
1583
- }
1584
- return;
1585
- }
1586
- // Configure SSO wizard
1587
- if (modal.kind === 'configure-sso') {
1588
- if (key.escape) {
1589
- dispatch({ type: 'CLOSE_MODAL' });
1590
- return;
1591
- }
1592
- const fieldMap = { 1: 'issuer', 2: 'clientId', 3: 'clientSecret', 4: 'domains' };
1593
- if (modal.step >= 1 && modal.step <= 4) {
1594
- const field = fieldMap[modal.step];
1595
- const current = modal[field];
1596
- if (key.return && current.length > 0) {
1597
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: modal.step + 1 } });
1598
- }
1599
- else if (key.backspace || key.delete) {
1600
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current.slice(0, -1) } });
1601
- }
1602
- else if (input === ' ') {
1603
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + ' ' } });
1604
- }
1605
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1606
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + input } });
1607
- }
1608
- }
1609
- else if (modal.step === 5) {
1610
- // Role selection (member=0, viewer=1, admin=2)
1611
- if (key.upArrow) {
1612
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.max(0, modal.defaultRole - 1) } });
1613
- }
1614
- else if (key.downArrow) {
1615
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.min(2, modal.defaultRole + 1) } });
1616
- }
1617
- else if (key.return) {
1618
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 6 } });
1619
- }
1620
- }
1621
- else if (modal.step === 6) {
1622
- // Require SSO toggle
1623
- if (input === 'y') {
1624
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: true, step: 7 } });
1625
- }
1626
- else if (input === 'n') {
1627
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: false, step: 7 } });
1628
- }
1629
- }
1630
- else if (modal.step === 7) {
1631
- // Confirm
1632
- if (key.return) {
1633
- const team = getActiveTeam(s);
1634
- if (team) {
1635
- const roles = ['member', 'viewer', 'admin'];
1636
- try {
1637
- dispatch({ type: 'SET_OPERATION', operation: 'Configuring SSO' });
1638
- const ssoPayload = {
1639
- issuer: modal.issuer,
1640
- client_id: modal.clientId,
1641
- client_secret: modal.clientSecret,
1642
- allowed_domains: modal.domains.split(',').map((d) => d.trim()).filter(Boolean),
1643
- default_role: roles[modal.defaultRole],
1644
- require_sso: modal.requireSso,
1645
- };
1646
- if (s.ssoConfig) {
1647
- await api.updateSso(team.team_id, ssoPayload);
1648
- }
1649
- else {
1650
- await api.configureSso(team.team_id, ssoPayload);
1651
- }
1652
- const ssoConfig = await api.getSso(team.team_id);
1653
- dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
1654
- dispatch({ type: 'SET_OPERATION', operation: null });
1655
- showToast('SSO configured', 'success');
1656
- }
1657
- catch {
1658
- dispatch({ type: 'SET_OPERATION', operation: null });
1659
- showToast('Failed to configure SSO', 'error');
1660
- }
1661
- }
1662
- dispatch({ type: 'CLOSE_MODAL' });
1663
- }
1664
- }
1665
- return;
1666
- }
1667
- }
1668
- // ---- Modal confirmation ----
1669
- async function confirmModal(modal) {
1670
- if (busyRef.current)
1671
- return false;
1672
- busyRef.current = true;
1673
- try {
1674
- return await confirmModalInner(modal);
1675
- }
1676
- finally {
1677
- busyRef.current = false;
1678
- }
1679
- }
1680
- async function confirmModalInner(modal) {
1681
- const s = stateRef.current;
1682
- // Logout doesn't need a project
1683
- if (modal.kind === 'confirm-logout') {
1684
- try {
1685
- const credentialsFile = join(homedir(), '.cohvu', 'credentials');
1686
- if (existsSync(credentialsFile))
1687
- unlinkSync(credentialsFile);
1688
- }
1689
- catch { }
1690
- exit();
1691
- return false;
1692
- }
1693
- // Account deletion flow.
1694
- if (modal.kind === 'confirm-account-delete') {
1695
- try {
1696
- dispatch({ type: 'SET_OPERATION', operation: 'Scheduling deletion' });
1697
- const result = await api.scheduleAccountDeletion();
1698
- dispatch({ type: 'SET_OPERATION', operation: null });
1699
- // Refresh the user record so the You tab reflects the locked state.
1700
- const me = await api.me();
1701
- dispatch({ type: 'SET_USER_DATA', me });
1702
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'account-delete-scheduled', scheduledAt: result.deletion_scheduled_at } });
1703
- }
1704
- catch (err) {
1705
- dispatch({ type: 'SET_OPERATION', operation: null });
1706
- // 409 sole-admin → redirect to the blocked modal with the team list.
1707
- // ApiError.body is a JSON string; parse it for the structured payload.
1708
- let handled = false;
1709
- const raw = err?.body;
1710
- if (typeof raw === 'string') {
1711
- try {
1712
- const parsed = JSON.parse(raw);
1713
- if (parsed.error?.code === 'SOLE_ADMIN' && parsed.error.details?.teams) {
1714
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'account-delete-blocked', teams: parsed.error.details.teams } });
1715
- handled = true;
1716
- }
1717
- }
1718
- catch { }
1719
- }
1720
- if (!handled) {
1721
- showToast('Failed to schedule deletion', 'error');
1722
- dispatch({ type: 'CLOSE_MODAL' });
1723
- }
1724
- }
1725
- return false;
1726
- }
1727
- if (modal.kind === 'confirm-cancel-account-deletion') {
1728
- try {
1729
- dispatch({ type: 'SET_OPERATION', operation: 'Canceling deletion' });
1730
- await api.cancelAccountDeletion();
1731
- const me = await api.me();
1732
- dispatch({ type: 'SET_USER_DATA', me });
1733
- dispatch({ type: 'SET_OPERATION', operation: null });
1734
- showToast('Deletion canceled — account restored', 'success');
1735
- }
1736
- catch {
1737
- dispatch({ type: 'SET_OPERATION', operation: null });
1738
- showToast('Failed to cancel deletion', 'error');
1739
- }
1740
- return true;
1741
- }
1742
- // These modals don't require an active project
1743
- if (modal.kind === 'create-project') {
1744
- if (!modal.input)
1745
- return false;
1746
- const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1747
- try {
1748
- dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
1749
- const project = modal.teamId
1750
- ? await api.createTeamProject(modal.teamId, modal.input, slug)
1751
- : await api.createProject(modal.input, slug);
1752
- await api.switchProject(project.id);
1753
- const me = await api.me();
1754
- dispatch({ type: 'SET_USER_DATA', me });
1755
- dispatch({ type: 'SET_OPERATION', operation: null });
1756
- const newProjectId = me.user.active_project_id;
1757
- if (newProjectId)
1758
- switchToProject(newProjectId);
1759
- showToast('Project created', 'success');
1760
- }
1761
- catch {
1762
- dispatch({ type: 'SET_OPERATION', operation: null });
1763
- showToast('Failed to create project', 'error');
1764
- }
1765
- return false;
1766
- }
1767
- if (modal.kind === 'create-team') {
1768
- if (!modal.input)
1769
- return false;
1770
- const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1771
- try {
1772
- dispatch({ type: 'SET_OPERATION', operation: 'Opening team checkout' });
1773
- // Team creation is gated on a team subscription — we hand the user
1774
- // to Stripe checkout; the webhook creates the team + sub atomically
1775
- // on payment. The new team appears on the next me() refresh.
1776
- const { checkout_url } = await api.createTeam(modal.input, slug);
1777
- if (checkout_url)
1778
- openBrowser(checkout_url);
1779
- dispatch({ type: 'SET_OPERATION', operation: null });
1780
- showToast('Complete checkout in your browser — team will appear after payment', 'info');
1781
- }
1782
- catch {
1783
- dispatch({ type: 'SET_OPERATION', operation: null });
1784
- showToast('Failed to start team checkout', 'error');
1785
- }
1786
- return false;
1787
- }
1788
- if (modal.kind === 'create-team-project') {
1789
- if (!modal.input)
1790
- return false;
1791
- const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1792
- try {
1793
- dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
1794
- const project = await api.createTeamProject(modal.teamId, modal.input, slug);
1795
- await api.switchProject(project.id);
1796
- const me = await api.me();
1797
- dispatch({ type: 'SET_USER_DATA', me });
1798
- dispatch({ type: 'SET_OPERATION', operation: null });
1799
- const newProjectId = me.user.active_project_id;
1800
- if (newProjectId)
1801
- switchToProject(newProjectId);
1802
- showToast('Team created', 'success');
1803
- }
1804
- catch {
1805
- dispatch({ type: 'SET_OPERATION', operation: null });
1806
- showToast('Failed to create project', 'error');
1807
- }
1808
- return false;
1809
- }
1810
- if (modal.kind === 'create-integration-key') {
1811
- if (modal.step !== 'confirm')
1812
- return false;
1813
- if (!modal.input.trim() || modal.selectedProjectIds.size === 0)
1814
- return false;
1815
- try {
1816
- dispatch({ type: 'SET_OPERATION', operation: 'Creating key' });
1817
- const projects = [...modal.selectedProjectIds].map(pid => ({
1818
- project_id: pid,
1819
- scope: modal.globalScope,
1820
- allowed_actions: [...(modal.projectScopes.get(pid) ?? [])],
1821
- }));
1822
- const result = await api.createApiKey({
1823
- kind: 'integration',
1824
- name: modal.input.trim(),
1825
- projects,
1826
- ...(modal.agentName.trim() ? { default_agent_name: modal.agentName.trim() } : {}),
1827
- ...(modal.expiresInDays ? { expires_in_days: parseInt(modal.expiresInDays, 10) } : {}),
1828
- ...(modal.opsLimit ? { ops_limit: parseInt(modal.opsLimit, 10) } : {}),
1829
- });
1830
- dispatch({ type: 'SET_OPERATION', operation: null });
1831
- const integrationKeys = stateRef.current.userRole === 'admin' && stateRef.current.activeProjectId
1832
- ? await api.listIntegrationKeys(stateRef.current.activeProjectId).catch(() => [])
1833
- : [];
1834
- dispatch({ type: 'SET_API_KEYS', keys: integrationKeys });
1835
- dispatch({ type: 'OPEN_MODAL', modal: {
1836
- kind: 'key-created',
1837
- keyId: result.api_key.id,
1838
- keyValue: result.key,
1839
- keyName: result.api_key.name,
1840
- kind2: 'integration',
1841
- } });
1842
- return true;
1843
- }
1844
- catch {
1845
- dispatch({ type: 'SET_OPERATION', operation: null });
1846
- showToast('Failed to create key', 'error');
1847
- }
1848
- return false;
1849
- }
1850
- if (modal.kind === 'confirm-revoke-key') {
1851
- try {
1852
- dispatch({ type: 'SET_OPERATION', operation: 'Revoking key' });
1853
- await api.deleteApiKey(modal.keyId);
1854
- dispatch({ type: 'REMOVE_API_KEY', id: modal.keyId });
1855
- dispatch({ type: 'SET_OPERATION', operation: null });
1856
- showToast('Key revoked', 'success');
1857
- }
1858
- catch {
1859
- dispatch({ type: 'SET_OPERATION', operation: null });
1860
- showToast('Failed to revoke key', 'error');
1861
- }
1862
- return false;
1863
- }
1864
- const projectId = s.activeProjectId;
1865
- if (!projectId)
1866
- return false;
1867
- switch (modal.kind) {
1868
- case 'approve-action': {
1869
- const team = getActiveTeam(s);
1870
- if (team && 'approvalId' in modal) {
1871
- try {
1872
- dispatch({ type: 'SET_OPERATION', operation: 'Approving' });
1873
- await api.approveAction(team.team_id, modal.approvalId);
1874
- const approvals = await api.listApprovals(team.team_id);
1875
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1876
- dispatch({ type: 'SET_OPERATION', operation: null });
1877
- const me = await api.me();
1878
- dispatch({ type: 'SET_USER_DATA', me });
1879
- await loadTabData();
1880
- showToast('Approved', 'success');
1881
- }
1882
- catch {
1883
- dispatch({ type: 'SET_OPERATION', operation: null });
1884
- showToast('Failed to approve', 'error');
1885
- }
1886
- }
1887
- break;
1888
- }
1889
- case 'confirm-remove':
1890
- try {
1891
- dispatch({ type: 'SET_OPERATION', operation: 'Removing contribution' });
1892
- await api.deleteContribution(projectId, modal.contributionId);
1893
- dispatch({ type: 'REMOVE_CONTRIBUTION', id: modal.contributionId });
1894
- dispatch({ type: 'SET_OPERATION', operation: null });
1895
- showToast('Contribution removed', 'success');
1896
- }
1897
- catch {
1898
- dispatch({ type: 'SET_OPERATION', operation: null });
1899
- showToast('Failed to remove contribution', 'error');
1900
- }
1901
- break;
1902
- case 'confirm-remove-all':
1903
- case 'confirm-clear': {
1904
- const project = getActiveProject(s);
1905
- if (project && modal.input === project.slug) {
1906
- try {
1907
- dispatch({ type: 'SET_OPERATION', operation: 'Clearing contributions' });
1908
- // Server gates this behind consensus for team projects. If the
1909
- // response includes pending_approval, the action is queued.
1910
- const resp = await api.clearContributions(projectId);
1911
- if (resp.pending_approval) {
1912
- const team = getActiveTeam(s);
1913
- if (team) {
1914
- const approvals = await api.listApprovals(team.team_id);
1915
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1916
- }
1917
- dispatch({ type: 'SET_OPERATION', operation: null });
1918
- showToast('Clear proposed — waiting for majority', 'info');
1919
- }
1920
- else {
1921
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: [], total: 0 });
1922
- dispatch({ type: 'SET_OPERATION', operation: null });
1923
- showToast('All contributions cleared', 'success');
1924
- }
1925
- }
1926
- catch {
1927
- dispatch({ type: 'SET_OPERATION', operation: null });
1928
- showToast('Failed to clear contributions', 'error');
1929
- }
1930
- }
1931
- break;
1932
- }
1933
- case 'confirm-delete': {
1934
- const project = getActiveProject(s);
1935
- if (project && modal.input === project.slug) {
1936
- try {
1937
- dispatch({ type: 'SET_OPERATION', operation: 'Deleting project' });
1938
- await api.deleteProject(projectId);
1939
- const me = await api.me();
1940
- dispatch({ type: 'SET_USER_DATA', me });
1941
- dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
1942
- dispatch({ type: 'SET_OPERATION', operation: null });
1943
- const newProjectId = me.user.active_project_id;
1944
- if (newProjectId) {
1945
- switchToProject(newProjectId);
1946
- }
1947
- else {
1948
- if (feedDisconnectRef.current) {
1949
- feedDisconnectRef.current();
1950
- feedDisconnectRef.current = null;
1951
- }
1952
- if (pollIntervalRef.current)
1953
- clearInterval(pollIntervalRef.current);
1954
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: [], total: 0 });
1955
- dispatch({ type: 'SET_MEMBERS', members: [] });
1956
- }
1957
- showToast('Project deleted', 'success');
1958
- }
1959
- catch {
1960
- dispatch({ type: 'SET_OPERATION', operation: null });
1961
- showToast('Failed to delete project', 'error');
1962
- }
1963
- }
1964
- break;
1965
- }
1966
- case 'confirm-remove-member': {
1967
- const teamForRemove = getActiveTeam(s);
1968
- if (teamForRemove) {
1969
- try {
1970
- dispatch({ type: 'SET_OPERATION', operation: 'Removing member' });
1971
- await api.removeTeamMember(teamForRemove.team_id, modal.userId);
1972
- const members = await api.listTeamMembers(teamForRemove.team_id);
1973
- dispatch({ type: 'SET_MEMBERS', members });
1974
- dispatch({ type: 'SET_OPERATION', operation: null });
1975
- showToast('Member removed', 'success');
1976
- }
1977
- catch {
1978
- dispatch({ type: 'SET_OPERATION', operation: null });
1979
- showToast('Failed to remove member', 'error');
1980
- }
1981
- }
1982
- break;
1983
- }
1984
- case 'confirm-leave': {
1985
- const teamForLeave = getActiveTeam(s);
1986
- if (teamForLeave) {
1987
- try {
1988
- dispatch({ type: 'SET_OPERATION', operation: 'Leaving' });
1989
- await api.removeTeamMember(teamForLeave.team_id, s.user.id);
1990
- const me = await api.me();
1991
- dispatch({ type: 'SET_USER_DATA', me });
1992
- dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
1993
- dispatch({ type: 'SET_OPERATION', operation: null });
1994
- const newProjectId = me.user.active_project_id;
1995
- if (newProjectId) {
1996
- switchToProject(newProjectId);
1997
- }
1998
- else {
1999
- if (feedDisconnectRef.current) {
2000
- feedDisconnectRef.current();
2001
- feedDisconnectRef.current = null;
2002
- }
2003
- if (pollIntervalRef.current)
2004
- clearInterval(pollIntervalRef.current);
2005
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: [], total: 0 });
2006
- dispatch({ type: 'SET_MEMBERS', members: [] });
2007
- }
2008
- showToast('Left', 'success');
2009
- }
2010
- catch {
2011
- dispatch({ type: 'SET_OPERATION', operation: null });
2012
- showToast('Failed to leave', 'error');
2013
- }
2014
- }
2015
- break;
2016
- }
2017
- // confirm-logout handled above (before projectId check)
2018
- case 'rename': {
2019
- if (!modal.input)
2020
- break;
2021
- const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2022
- try {
2023
- dispatch({ type: 'SET_OPERATION', operation: 'Renaming' });
2024
- await api.renameProject(projectId, modal.input, slug);
2025
- const me = await api.me();
2026
- dispatch({ type: 'SET_USER_DATA', me });
2027
- dispatch({ type: 'SET_OPERATION', operation: null });
2028
- showToast('Project renamed', 'success');
2029
- }
2030
- catch {
2031
- dispatch({ type: 'SET_OPERATION', operation: null });
2032
- showToast('Failed to rename', 'error');
2033
- }
2034
- break;
2035
- }
2036
- case 'confirm-regen-link': {
2037
- const teamForRegen = getActiveTeam(s);
2038
- if (teamForRegen) {
2039
- try {
2040
- dispatch({ type: 'SET_OPERATION', operation: 'Regenerating link' });
2041
- await api.regenerateTeamInviteLink(teamForRegen.team_id, modal.role);
2042
- const links = await api.listTeamInviteLinks(teamForRegen.team_id);
2043
- dispatch({ type: 'SET_INVITE_LINKS', links });
2044
- dispatch({ type: 'SET_OPERATION', operation: null });
2045
- showToast('Link regenerated', 'success');
2046
- }
2047
- catch {
2048
- dispatch({ type: 'SET_OPERATION', operation: null });
2049
- showToast('Failed to regenerate link', 'error');
2050
- }
2051
- }
2052
- break;
2053
- }
2054
- case 'confirm-rename-team': {
2055
- if (!modal.input)
2056
- break;
2057
- const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2058
- const teamForRename = getActiveTeam(s);
2059
- if (teamForRename) {
2060
- try {
2061
- dispatch({ type: 'SET_OPERATION', operation: 'Renaming team' });
2062
- await api.renameTeam(teamForRename.team_id, modal.input, slug);
2063
- const me = await api.me();
2064
- dispatch({ type: 'SET_USER_DATA', me });
2065
- dispatch({ type: 'SET_OPERATION', operation: null });
2066
- showToast('Team renamed', 'success');
2067
- }
2068
- catch {
2069
- dispatch({ type: 'SET_OPERATION', operation: null });
2070
- showToast('Failed to rename team', 'error');
2071
- }
2072
- }
2073
- break;
2074
- }
2075
- case 'confirm-delete-team': {
2076
- const teamForDelete = getActiveTeam(s);
2077
- if (teamForDelete && modal.input === modal.slug) {
2078
- try {
2079
- dispatch({ type: 'SET_OPERATION', operation: 'Deleting team' });
2080
- const resp = await api.deleteTeam(teamForDelete.team_id);
2081
- if (resp.pending_approval) {
2082
- const approvals = await api.listApprovals(teamForDelete.team_id);
2083
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
2084
- dispatch({ type: 'SET_OPERATION', operation: null });
2085
- showToast('Team deletion proposed — waiting for majority', 'info');
2086
- break;
2087
- }
2088
- const me = await api.me();
2089
- dispatch({ type: 'SET_USER_DATA', me });
2090
- dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
2091
- dispatch({ type: 'SET_OPERATION', operation: null });
2092
- const newProjectId = me.user.active_project_id;
2093
- if (newProjectId) {
2094
- switchToProject(newProjectId);
2095
- }
2096
- else {
2097
- if (feedDisconnectRef.current) {
2098
- feedDisconnectRef.current();
2099
- feedDisconnectRef.current = null;
2100
- }
2101
- if (pollIntervalRef.current)
2102
- clearInterval(pollIntervalRef.current);
2103
- dispatch({ type: 'SET_CONTRIBUTIONS', contributions: [], total: 0 });
2104
- dispatch({ type: 'SET_MEMBERS', members: [] });
2105
- }
2106
- showToast('Team deleted', 'success');
2107
- }
2108
- catch {
2109
- dispatch({ type: 'SET_OPERATION', operation: null });
2110
- showToast('Failed to delete team', 'error');
2111
- }
2112
- }
2113
- break;
2114
- }
2115
- }
2116
- return false;
2117
- }
2118
- // ---- Small terminal guard ----
2119
- if (cols < 80 || rows < 20) {
2120
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Box, { height: 1 }), _jsx(Text, { color: "gray", children: " terminal too small" }), _jsx(Text, { color: "gray", dimColor: true, children: " resize to continue" })] }));
2121
- }
2122
- // ---- Determine content height ----
2123
- // Header + divider + (banner + divider?) + tabbar + divider = top
2124
- // toast? + operation? + divider + footer = bottom
2125
- const hasBanners = state.notifications.some(n => !n.seen) || hasBillingBanner(state) || state.firstLogin || !!state.joinedProjectName || state.pendingApprovals.some(a => new Date(a.expires_at) > new Date());
2126
- const topLines = 2 + (hasBanners ? 2 : 0) + 2; // header+div, (banner+div), tabbar+div
2127
- const bottomLines = (state.toast ? 1 : 0) + (state.operationPending ? 1 : 0) + 2; // div + footer
2128
- const contentHeight = Math.max(1, rows - topLines - bottomLines);
2129
- // ---- Render ----
2130
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Header, { state: state }), _jsx(Divider, {}), _jsx(Banner, { state: state }), hasBanners && _jsx(Divider, {}), _jsx(TabBar, { active: state.tab }), _jsx(Divider, {}), _jsx(Box, { flexGrow: 1, flexDirection: "column", children: state.showHelp
2131
- ? _jsx(HelpOverlay, { state: state })
2132
- : state.modal
2133
- ? _jsx(ModalView, { state: state, height: contentHeight })
2134
- : renderTab(state, contentHeight) }), state.toast && _jsx(Toast, { toast: state.toast }), state.operationPending && _jsxs(Text, { color: "gray", dimColor: true, children: [" ", state.operationPending, "..."] }), _jsx(Divider, {}), _jsx(Footer, { state: state })] }));
2135
- }
2136
- function renderTab(state, height) {
2137
- switch (state.tab) {
2138
- case 'knowledge': return _jsx(KnowledgeTab, { state: state, height: height });
2139
- case 'team': return _jsx(TeamTab, { state: state, height: height });
2140
- case 'billing': return _jsx(BillingTab, { state: state, height: height });
2141
- case 'project': return _jsx(ProjectTab, { state: state, height: height });
2142
- case 'keys': return _jsx(KeysTab, { state: state, height: height });
2143
- case 'you': return _jsx(YouTab, { state: state, height: height });
2144
- }
2145
- }
2146
- // Projects the caller can scope an integration key to. Personal projects are
2147
- // always scopable by their owner; team projects only by team admins.
2148
- function adminScopableProjects(s) {
2149
- return s.projects.filter(p => {
2150
- if (p.owner.kind === 'personal')
2151
- return true; // user owns it
2152
- const teamId = p.owner.teamId;
2153
- const team = s.teams.find(t => t.team_id === teamId);
2154
- return team?.role === 'admin';
2155
- });
2156
- }
2157
- function HelpOverlay({ state }) {
2158
- const lines = [
2159
- ['tab / 1-6', 'switch tabs'],
2160
- ['?', 'toggle this help'],
2161
- ['q', 'quit'],
2162
- ['', ''],
2163
- ];
2164
- switch (state.tab) {
2165
- case 'knowledge':
2166
- lines.push(['/', 'search'], ['enter', 'expand contribution'], ['↑↓', 'navigate'], ['space', 'load more'], ['d', 'remove mode'], ['D', 'remove all (admin)']);
2167
- break;
2168
- case 'team':
2169
- lines.push(['↑↓', 'navigate'], ['i', 'invite'], ['e', 'edit role (admin)'], ['x', 'remove member'], ['c', 'copy invite link'], ['r', 'regenerate link']);
2170
- break;
2171
- case 'billing':
2172
- lines.push(['s', 'subscribe'], ['p', 'billing portal']);
2173
- break;
2174
- case 'project':
2175
- lines.push(['↑↓', 'navigate links'], ['w', 'switch project'], ['n', 'new project'], ['t', 'new team'], ['r', 'rename'], ['c', 'clear knowledge'], ['d', 'delete project']);
2176
- break;
2177
- case 'keys':
2178
- lines.push(['↑↓', 'navigate'], ['n', 'new key'], ['x', 'revoke selected']);
2179
- break;
2180
- case 'you':
2181
- lines.push(['p', 'pause / resume'], ['r', 're-run setup'], ['l', 'logout']);
2182
- break;
2183
- }
2184
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { height: 1 }), _jsxs(Text, { color: "gray", children: [" keybindings \u00B7 ", state.tab] }), _jsx(Box, { height: 1 }), lines.map(([key, desc], i) => key === '' ? _jsx(Box, { height: 1 }, i) : (_jsxs(Box, { children: [_jsx(Box, { width: 16, children: _jsxs(Text, { color: "gray", children: [" ", key] }) }), _jsx(Text, { dimColor: true, children: desc })] }, i))), _jsx(Box, { height: 1 }), _jsx(Text, { color: "gray", dimColor: true, children: " press ? or esc to close" })] }));
2185
- }
2186
- function hasPaidPlan(state) {
2187
- const project = getActiveProject(state);
2188
- if (!project)
2189
- return false;
2190
- if (project.owner.kind === 'team') {
2191
- const team = getActiveTeam(state);
2192
- const sub = team?.subscription;
2193
- return !!sub && (sub.status === 'active' || sub.status === 'past_due');
2194
- }
2195
- const sub = state.individualSubscription;
2196
- return !!sub && (sub.status === 'active' || sub.status === 'past_due');
2197
- }
2198
- function hasBillingBanner(state) {
2199
- const project = getActiveProject(state);
2200
- if (!project)
2201
- return false;
2202
- if (project.owner.kind === 'team') {
2203
- const team = getActiveTeam(state);
2204
- const sub = team?.subscription;
2205
- if (sub?.status === 'past_due')
2206
- return true;
2207
- return false;
2208
- }
2209
- const sub = state.individualSubscription;
2210
- if (sub?.status === 'past_due')
2211
- return true;
2212
- return false;
2213
- }
2214
- // Helper: derive flat projects from MeResponse (same logic as state.ts)
2215
- function deriveFlatProjectsFromMe(me) {
2216
- const list = [];
2217
- for (const p of me.personal_projects) {
2218
- list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, locked: p.locked ?? false, owner: { kind: 'personal' } });
2219
- }
2220
- for (const team of me.teams) {
2221
- const teamCanceled = team.subscription?.status === 'canceled';
2222
- for (const p of team.projects) {
2223
- list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, locked: teamCanceled, owner: { kind: 'team', teamId: team.team_id, teamName: team.name, teamSlug: team.slug } });
2224
- }
2225
- }
2226
- return list;
2227
- }
2228
- function loadPersistedState() {
2229
- try {
2230
- if (existsSync(STATE_FILE))
2231
- return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
2232
- }
2233
- catch { }
2234
- return { hasOpenedDashboard: false };
2235
- }
2236
- function savePersistedState(data) {
2237
- try {
2238
- const dir = join(homedir(), '.cohvu');
2239
- if (!existsSync(dir))
2240
- mkdirSync(dir, { recursive: true, mode: 0o700 });
2241
- writeFileSync(STATE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
2242
- }
2243
- catch { }
2244
- }
2245
- //# sourceMappingURL=App.js.map