cohvu 2.2.9 → 2.3.1

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.
package/dist/tui/App.js CHANGED
@@ -19,7 +19,7 @@ import { TeamTab } from './tabs/TeamTab.js';
19
19
  import { BillingTab } from './tabs/BillingTab.js';
20
20
  import { ProjectTab } from './tabs/ProjectTab.js';
21
21
  import { YouTab } from './tabs/YouTab.js';
22
- import { daysUntil } from './utils.js';
22
+ import { daysUntil, timeUntil } from './utils.js';
23
23
  import { exec, execFile } from 'child_process';
24
24
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, watch } from 'fs';
25
25
  import { join } from 'path';
@@ -72,6 +72,12 @@ export default function App() {
72
72
  execFile(cmd, [url], () => { });
73
73
  }
74
74
  }
75
+ function shouldRequireConsensus(s) {
76
+ if (!s.requireConsensus)
77
+ return false;
78
+ const admins = s.members.filter(m => m.role === 'admin');
79
+ return admins.length >= 2;
80
+ }
75
81
  function copyToClipboard(text) {
76
82
  const cmd = process.platform === 'darwin' ? 'pbcopy'
77
83
  : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard';
@@ -88,7 +94,7 @@ export default function App() {
88
94
  if (eventType === 'memory') {
89
95
  const event = data;
90
96
  if (event.operation === 'create') {
91
- dispatch({ type: 'ADD_MEMORY', memory: { id: event.id, body: event.body, updated_at: event.updated_at } });
97
+ dispatch({ type: 'ADD_MEMORY', memory: { id: event.id, body: event.body, updated_at: event.updated_at, contributed_by: event.contributed_by, memory_type: event.memory_type } });
92
98
  dispatch({ type: 'SET_LIVE_DOT', memoryId: event.id });
93
99
  if (liveDotTimerRef.current)
94
100
  clearTimeout(liveDotTimerRef.current);
@@ -124,6 +130,14 @@ export default function App() {
124
130
  }
125
131
  });
126
132
  }
133
+ else if (eventType === 'approval') {
134
+ const team = getActiveTeam(stateRef.current);
135
+ if (team) {
136
+ api.listApprovals(team.team_id).then(approvals => {
137
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
138
+ }).catch(() => { });
139
+ }
140
+ }
127
141
  },
128
142
  onConnected: () => {
129
143
  dispatch({ type: 'SET_SSE_CONNECTED', connected: true });
@@ -163,6 +177,8 @@ export default function App() {
163
177
  ]);
164
178
  dispatch({ type: 'SET_MEMBERS', members });
165
179
  dispatch({ type: 'SET_INVITE_LINKS', links });
180
+ const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
181
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
166
182
  }
167
183
  else {
168
184
  dispatch({ type: 'SET_MEMBERS', members: [] });
@@ -231,9 +247,7 @@ export default function App() {
231
247
  if (cancelled)
232
248
  return;
233
249
  dispatch({ type: 'SET_USER_DATA', me });
234
- await runSetup();
235
- if (cancelled)
236
- return;
250
+ // Detect platforms without re-running setup (setup already ran in enterDashboard)
237
251
  const platforms = detectPlatformStatuses();
238
252
  dispatch({ type: 'SET_PLATFORMS', platforms });
239
253
  const flatProjects = deriveFlatProjectsFromMe(me);
@@ -268,6 +282,16 @@ export default function App() {
268
282
  dispatch({ type: 'SET_BILLING', billing });
269
283
  dispatch({ type: 'SET_INVITE_LINKS', links: inviteLinks });
270
284
  dispatch({ type: 'SET_NOTIFICATIONS', notifications });
285
+ if (isTeamProject && activeTeam) {
286
+ dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: activeTeam.require_consensus ?? false });
287
+ const ssoConfig = await api.getSso(activeTeam.team_id).catch(() => null);
288
+ if (ssoConfig)
289
+ dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
290
+ }
291
+ if (isTeamProject && activeTeam) {
292
+ const approvals = await api.listApprovals(activeTeam.team_id).catch(() => []);
293
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
294
+ }
271
295
  if (notifications.length > 0)
272
296
  api.markNotificationsSeen().catch(() => { });
273
297
  connectFeed(projectId);
@@ -362,17 +386,48 @@ export default function App() {
362
386
  await handleModalKey(input, key);
363
387
  return;
364
388
  }
389
+ // Approval keys (when approvals exist, on team/project tab, team project)
390
+ if ((input === 'a' || input === 'x') && !s.modal && s.pendingApprovals.length > 0 && (s.tab === 'team' || s.tab === 'project')) {
391
+ const activeProject = getActiveProject(s);
392
+ if (activeProject?.owner.kind === 'team') {
393
+ const approval = s.pendingApprovals[0];
394
+ if (input === 'a') {
395
+ dispatch({ type: 'OPEN_MODAL', modal: {
396
+ kind: 'approve-action',
397
+ approvalId: approval.id,
398
+ description: approval.description,
399
+ initiator: approval.initiator_email,
400
+ expiresIn: timeUntil(approval.expires_at),
401
+ } });
402
+ }
403
+ else if (input === 'x') {
404
+ const team = getActiveTeam(s);
405
+ if (team) {
406
+ try {
407
+ await api.cancelApproval(team.team_id, approval.id);
408
+ const approvals = await api.listApprovals(team.team_id);
409
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
410
+ showToast('Canceled', 'success');
411
+ }
412
+ catch {
413
+ showToast('Failed to cancel', 'error');
414
+ }
415
+ }
416
+ }
417
+ return;
418
+ }
419
+ }
365
420
  // Tab switching
366
421
  if (key.tab) {
367
422
  dispatch({ type: 'NEXT_TAB' });
368
- await loadTabData();
423
+ setTimeout(() => loadTabData(), 0);
369
424
  return;
370
425
  }
371
426
  // Number keys switch tabs
372
427
  const tabIdx = parseInt(input, 10);
373
428
  if (tabIdx >= 1 && tabIdx <= 5 && !key.ctrl && !key.meta) {
374
429
  dispatch({ type: 'SWITCH_TAB', tab: TABS[tabIdx - 1] });
375
- await loadTabData();
430
+ setTimeout(() => loadTabData(), 0);
376
431
  return;
377
432
  }
378
433
  // Global 'b' shortcut — subscribe/billing portal from banner
@@ -514,11 +569,12 @@ export default function App() {
514
569
  return;
515
570
  const memberCount = s.members.length;
516
571
  const linkRoles = ['admin', 'member', 'viewer'];
517
- const linkCount = s.userRole === 'admin' ? linkRoles.length : 0;
518
- const totalRows = memberCount + linkCount;
572
+ // Settings rows (admin only): name, consensus, sso, then 3 invite link rows
573
+ const settingsCount = s.userRole === 'admin' ? 6 : 0; // 3 settings + 3 links
574
+ const totalRows = memberCount + settingsCount;
519
575
  const sel = s.teamSelected;
520
- const onLinkRow = s.userRole === 'admin' && sel >= memberCount;
521
576
  const onMemberRow = sel < memberCount;
577
+ const onLinkRow = s.userRole === 'admin' && sel >= memberCount + 3;
522
578
  const team = getActiveTeam(s);
523
579
  if (key.upArrow) {
524
580
  dispatch({ type: 'SET_TEAM_SELECTED', index: Math.max(0, sel - 1) });
@@ -528,8 +584,54 @@ export default function App() {
528
584
  dispatch({ type: 'SET_TEAM_SELECTED', index: Math.min(totalRows - 1, sel + 1) });
529
585
  return;
530
586
  }
587
+ // 'i' key (admin) — open invite modal
588
+ if (input === 'i' && s.userRole === 'admin') {
589
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite', selected: 0 } });
590
+ return;
591
+ }
592
+ // 'r' key on name row — rename team
593
+ if (input === 'r' && s.userRole === 'admin' && sel === memberCount + 0) {
594
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-rename-team', input: '' } });
595
+ return;
596
+ }
597
+ // 'r' key on invite link row — regen link
598
+ if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
599
+ const linkIdx = sel - memberCount - 3;
600
+ const role = linkRoles[linkIdx];
601
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
602
+ return;
603
+ }
604
+ // Enter on consensus row — toggle
605
+ if (key.return && s.userRole === 'admin' && sel === memberCount + 1) {
606
+ if (team) {
607
+ try {
608
+ const newValue = !s.requireConsensus;
609
+ await api.updateTeamSettings(team.team_id, { require_consensus: newValue });
610
+ dispatch({ type: 'SET_REQUIRE_CONSENSUS', value: newValue });
611
+ showToast(newValue ? 'Consensus required' : 'Consensus off', 'success');
612
+ }
613
+ catch {
614
+ showToast('Failed to update', 'error');
615
+ }
616
+ }
617
+ return;
618
+ }
619
+ // 's' key on SSO row — configure or manage SSO
620
+ if (input === 's' && s.userRole === 'admin' && sel === memberCount + 2) {
621
+ if (s.ssoConfig) {
622
+ // SSO already exists — offer edit/delete
623
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'manage-sso' } });
624
+ }
625
+ else {
626
+ dispatch({ type: 'OPEN_MODAL', modal: {
627
+ kind: 'configure-sso', step: 1, issuer: '', clientId: '', clientSecret: '', domains: '', defaultRole: 0, requireSso: false,
628
+ } });
629
+ }
630
+ return;
631
+ }
632
+ // 'c' key on invite link row — copy link
531
633
  if (input === 'c' && s.userRole === 'admin' && onLinkRow) {
532
- const linkIdx = sel - memberCount;
634
+ const linkIdx = sel - memberCount - 3;
533
635
  const role = linkRoles[linkIdx];
534
636
  const link = s.inviteLinks.find(l => l.role === role);
535
637
  if (link) {
@@ -541,12 +643,23 @@ export default function App() {
541
643
  }
542
644
  return;
543
645
  }
544
- if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
545
- const linkIdx = sel - memberCount;
646
+ // 'o' key on invite link row open in browser
647
+ if (input === 'o' && s.userRole === 'admin' && onLinkRow) {
648
+ const linkIdx = sel - memberCount - 3;
546
649
  const role = linkRoles[linkIdx];
547
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
650
+ const link = s.inviteLinks.find(l => l.role === role);
651
+ if (link)
652
+ openBrowser(link.url);
653
+ return;
654
+ }
655
+ // 'd' key (admin) — delete team
656
+ if (input === 'd' && s.userRole === 'admin') {
657
+ if (team) {
658
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-delete-team', slug: team.slug, teamName: team.name, input: '' } });
659
+ }
548
660
  return;
549
661
  }
662
+ // 'e' key on member row — edit role
550
663
  if (input === 'e' && s.userRole === 'admin' && onMemberRow) {
551
664
  const target = s.members[sel];
552
665
  if (target) {
@@ -565,6 +678,7 @@ export default function App() {
565
678
  }
566
679
  return;
567
680
  }
681
+ // 'x' key — remove member or leave
568
682
  if (input === 'x') {
569
683
  if (s.userRole === 'admin' && onMemberRow && team) {
570
684
  const target = s.members[sel];
@@ -637,11 +751,20 @@ export default function App() {
637
751
  // ---- Project keys ----
638
752
  async function handleProjectKey(input, _key) {
639
753
  const s = stateRef.current;
754
+ if (input === 't') {
755
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team', input: '' } });
756
+ return;
757
+ }
640
758
  if (input === 'r' && s.userRole === 'admin') {
641
759
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'rename', input: '' } });
642
760
  }
643
761
  else if (input === 'n') {
644
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '' } });
762
+ if (s.teams.length > 0) {
763
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'select-owner', selected: 0 } });
764
+ }
765
+ else {
766
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
767
+ }
645
768
  }
646
769
  else if (input === 'w' && s.projects.length > 1) {
647
770
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: 0 } });
@@ -756,8 +879,9 @@ export default function App() {
756
879
  // Text input modals
757
880
  if ('input' in modal) {
758
881
  if (key.return) {
759
- await confirmModal(modal);
760
- dispatch({ type: 'CLOSE_MODAL' });
882
+ const chained = await confirmModal(modal);
883
+ if (!chained)
884
+ dispatch({ type: 'CLOSE_MODAL' });
761
885
  }
762
886
  else if (key.backspace) {
763
887
  dispatch({ type: 'MODAL_BACKSPACE' });
@@ -781,19 +905,25 @@ export default function App() {
781
905
  else if (key.return) {
782
906
  if (modal.selected === s.projects.length) {
783
907
  dispatch({ type: 'CLOSE_MODAL' });
784
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '' } });
908
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
785
909
  }
786
910
  else {
787
911
  const project = s.projects[modal.selected];
788
912
  if (project) {
789
- await api.switchProject(project.project_id);
790
- const me = await api.me();
791
- dispatch({ type: 'SET_USER_DATA', me });
792
- dispatch({ type: 'CLOSE_MODAL' });
793
- const newProjectId = me.user.active_project_id;
794
- if (newProjectId)
795
- connectFeed(newProjectId);
796
- await loadTabData();
913
+ try {
914
+ await api.switchProject(project.project_id);
915
+ const me = await api.me();
916
+ dispatch({ type: 'SET_USER_DATA', me });
917
+ dispatch({ type: 'CLOSE_MODAL' });
918
+ const newProjectId = me.user.active_project_id;
919
+ if (newProjectId)
920
+ connectFeed(newProjectId);
921
+ setTimeout(() => loadTabData(), 0);
922
+ }
923
+ catch {
924
+ dispatch({ type: 'CLOSE_MODAL' });
925
+ showToast('Failed to switch project', 'error');
926
+ }
797
927
  }
798
928
  }
799
929
  }
@@ -819,6 +949,23 @@ export default function App() {
819
949
  return;
820
950
  }
821
951
  }
952
+ if (shouldRequireConsensus(stateRef.current) && modal.currentRole === 'admin' && newRole !== 'admin') {
953
+ const team = getActiveTeam(stateRef.current);
954
+ if (team) {
955
+ try {
956
+ await api.initiateApproval(team.team_id, 'demote_admin', `demote ${modal.targetEmail} from admin`, modal.targetUserId);
957
+ const approvals = await api.listApprovals(team.team_id);
958
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
959
+ dispatch({ type: 'CLOSE_MODAL' });
960
+ showToast('Approval requested', 'info');
961
+ }
962
+ catch {
963
+ showToast('Failed to request approval', 'error');
964
+ dispatch({ type: 'CLOSE_MODAL' });
965
+ }
966
+ return;
967
+ }
968
+ }
822
969
  const changeTeam = getActiveTeam(s);
823
970
  if (changeTeam) {
824
971
  try {
@@ -835,6 +982,175 @@ export default function App() {
835
982
  dispatch({ type: 'CLOSE_MODAL' });
836
983
  }
837
984
  }
985
+ // Select owner modal
986
+ if (modal.kind === 'select-owner') {
987
+ const itemCount = 1 + s.teams.length; // "personal" + each team
988
+ if (key.upArrow) {
989
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
990
+ }
991
+ else if (key.downArrow) {
992
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(itemCount - 1, modal.selected + 1) } });
993
+ }
994
+ else if (key.return) {
995
+ dispatch({ type: 'CLOSE_MODAL' });
996
+ if (modal.selected === 0) {
997
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: null } });
998
+ }
999
+ else {
1000
+ const team = s.teams[modal.selected - 1];
1001
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '', teamId: team.team_id } });
1002
+ }
1003
+ }
1004
+ return;
1005
+ }
1006
+ // Invite modal — select role
1007
+ if (modal.kind === 'invite') {
1008
+ const roles = ['admin', 'member', 'viewer'];
1009
+ if (key.upArrow) {
1010
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.max(0, modal.selected - 1) } });
1011
+ }
1012
+ else if (key.downArrow) {
1013
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: Math.min(2, modal.selected + 1) } });
1014
+ }
1015
+ else if (key.return) {
1016
+ const role = roles[modal.selected];
1017
+ const link = s.inviteLinks.find(l => l.role === role);
1018
+ if (link) {
1019
+ dispatch({ type: 'CLOSE_MODAL' });
1020
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role, url: link.url } });
1021
+ }
1022
+ else {
1023
+ dispatch({ type: 'CLOSE_MODAL' });
1024
+ showToast('Invite link not available', 'error');
1025
+ }
1026
+ }
1027
+ return;
1028
+ }
1029
+ // Invite link modal — copy or open
1030
+ if (modal.kind === 'invite-link') {
1031
+ if (input === 'c') {
1032
+ copyToClipboard(modal.url);
1033
+ dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
1034
+ if (copiedTimerRef.current)
1035
+ clearTimeout(copiedTimerRef.current);
1036
+ copiedTimerRef.current = setTimeout(() => dispatch({ type: 'SET_COPIED_FEEDBACK', active: false }), 1500);
1037
+ }
1038
+ else if (input === 'o') {
1039
+ openBrowser(modal.url);
1040
+ showToast('Opened in browser', 'info');
1041
+ }
1042
+ return;
1043
+ }
1044
+ // Manage existing SSO — edit or delete
1045
+ if (modal.kind === 'manage-sso') {
1046
+ if (input === 'e') {
1047
+ // Edit — open wizard pre-filled with current values
1048
+ const sso = s.ssoConfig;
1049
+ dispatch({ type: 'CLOSE_MODAL' });
1050
+ dispatch({ type: 'OPEN_MODAL', modal: {
1051
+ kind: 'configure-sso', step: 1,
1052
+ issuer: sso?.issuer ?? '', clientId: '', clientSecret: '',
1053
+ domains: sso?.allowed_domains.join(', ') ?? '', defaultRole: ['member', 'viewer', 'admin'].indexOf(sso?.default_role ?? 'member'),
1054
+ requireSso: sso?.require_sso ?? false,
1055
+ } });
1056
+ }
1057
+ else if (input === 'd') {
1058
+ const team = getActiveTeam(s);
1059
+ if (team) {
1060
+ try {
1061
+ await api.deleteSso(team.team_id);
1062
+ dispatch({ type: 'SET_SSO_CONFIG', config: null });
1063
+ dispatch({ type: 'CLOSE_MODAL' });
1064
+ showToast('SSO removed', 'success');
1065
+ }
1066
+ catch {
1067
+ showToast('Failed to remove SSO', 'error');
1068
+ }
1069
+ }
1070
+ }
1071
+ return;
1072
+ }
1073
+ // Configure SSO wizard
1074
+ if (modal.kind === 'configure-sso') {
1075
+ if (key.escape) {
1076
+ dispatch({ type: 'CLOSE_MODAL' });
1077
+ return;
1078
+ }
1079
+ const fieldMap = { 1: 'issuer', 2: 'clientId', 3: 'clientSecret', 4: 'domains' };
1080
+ if (modal.step >= 1 && modal.step <= 4) {
1081
+ const field = fieldMap[modal.step];
1082
+ const current = modal[field];
1083
+ if (key.return && current.length > 0) {
1084
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: modal.step + 1 } });
1085
+ }
1086
+ else if (key.backspace) {
1087
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current.slice(0, -1) } });
1088
+ }
1089
+ else if (input === ' ') {
1090
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + ' ' } });
1091
+ }
1092
+ else if (input.length === 1 && !key.ctrl && !key.meta) {
1093
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, [field]: current + input } });
1094
+ }
1095
+ }
1096
+ else if (modal.step === 5) {
1097
+ // Role selection (member=0, viewer=1, admin=2)
1098
+ if (key.upArrow) {
1099
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.max(0, modal.defaultRole - 1) } });
1100
+ }
1101
+ else if (key.downArrow) {
1102
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, defaultRole: Math.min(2, modal.defaultRole + 1) } });
1103
+ }
1104
+ else if (key.return) {
1105
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 6 } });
1106
+ }
1107
+ }
1108
+ else if (modal.step === 6) {
1109
+ // Require SSO toggle
1110
+ if (input === 'y') {
1111
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: true, step: 7 } });
1112
+ }
1113
+ else if (input === 'n') {
1114
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, requireSso: false, step: 7 } });
1115
+ }
1116
+ }
1117
+ else if (modal.step === 7) {
1118
+ // Confirm
1119
+ if (key.return) {
1120
+ const team = getActiveTeam(s);
1121
+ if (team) {
1122
+ const roles = ['member', 'viewer', 'admin'];
1123
+ try {
1124
+ dispatch({ type: 'SET_OPERATION', operation: 'Configuring SSO' });
1125
+ const ssoPayload = {
1126
+ issuer: modal.issuer,
1127
+ client_id: modal.clientId,
1128
+ client_secret: modal.clientSecret,
1129
+ allowed_domains: modal.domains.split(',').map((d) => d.trim()).filter(Boolean),
1130
+ default_role: roles[modal.defaultRole],
1131
+ require_sso: modal.requireSso,
1132
+ };
1133
+ if (s.ssoConfig) {
1134
+ await api.updateSso(team.team_id, ssoPayload);
1135
+ }
1136
+ else {
1137
+ await api.configureSso(team.team_id, ssoPayload);
1138
+ }
1139
+ const ssoConfig = await api.getSso(team.team_id);
1140
+ dispatch({ type: 'SET_SSO_CONFIG', config: ssoConfig });
1141
+ dispatch({ type: 'SET_OPERATION', operation: null });
1142
+ showToast('SSO configured', 'success');
1143
+ }
1144
+ catch {
1145
+ dispatch({ type: 'SET_OPERATION', operation: null });
1146
+ showToast('Failed to configure SSO', 'error');
1147
+ }
1148
+ }
1149
+ dispatch({ type: 'CLOSE_MODAL' });
1150
+ }
1151
+ }
1152
+ return;
1153
+ }
838
1154
  }
839
1155
  // ---- Modal confirmation ----
840
1156
  async function confirmModal(modal) {
@@ -847,13 +1163,34 @@ export default function App() {
847
1163
  unlinkSync(credentialsFile);
848
1164
  }
849
1165
  catch { }
850
- process.stdout.write('\x1b[?25h\x1b[0m\x1b[?1049l');
851
- process.exit(0);
1166
+ exit();
1167
+ return false;
852
1168
  }
853
1169
  const projectId = s.activeProjectId;
854
1170
  if (!projectId)
855
- return;
1171
+ return false;
856
1172
  switch (modal.kind) {
1173
+ case 'approve-action': {
1174
+ const team = getActiveTeam(s);
1175
+ if (team && 'approvalId' in modal) {
1176
+ try {
1177
+ dispatch({ type: 'SET_OPERATION', operation: 'Approving' });
1178
+ await api.approveAction(team.team_id, modal.approvalId);
1179
+ const approvals = await api.listApprovals(team.team_id);
1180
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1181
+ dispatch({ type: 'SET_OPERATION', operation: null });
1182
+ const me = await api.me();
1183
+ dispatch({ type: 'SET_USER_DATA', me });
1184
+ await loadTabData();
1185
+ showToast('Approved', 'success');
1186
+ }
1187
+ catch {
1188
+ dispatch({ type: 'SET_OPERATION', operation: null });
1189
+ showToast('Failed to approve', 'error');
1190
+ }
1191
+ }
1192
+ break;
1193
+ }
857
1194
  case 'confirm-forget':
858
1195
  try {
859
1196
  dispatch({ type: 'SET_OPERATION', operation: 'Removing memory' });
@@ -871,6 +1208,21 @@ export default function App() {
871
1208
  case 'confirm-clear': {
872
1209
  const project = getActiveProject(s);
873
1210
  if (project && modal.input === project.slug) {
1211
+ if (shouldRequireConsensus(s)) {
1212
+ const team = getActiveTeam(s);
1213
+ if (team) {
1214
+ try {
1215
+ await api.initiateApproval(team.team_id, 'clear_memories', `clear all memories from "${project.slug}"`, projectId);
1216
+ const approvals = await api.listApprovals(team.team_id);
1217
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1218
+ showToast('Approval requested', 'info');
1219
+ }
1220
+ catch {
1221
+ showToast('Failed to request approval', 'error');
1222
+ }
1223
+ break;
1224
+ }
1225
+ }
874
1226
  try {
875
1227
  dispatch({ type: 'SET_OPERATION', operation: 'Clearing memories' });
876
1228
  await api.clearMemories(projectId);
@@ -888,6 +1240,21 @@ export default function App() {
888
1240
  case 'confirm-delete': {
889
1241
  const project = getActiveProject(s);
890
1242
  if (project && modal.input === project.slug) {
1243
+ if (shouldRequireConsensus(s)) {
1244
+ const team = getActiveTeam(s);
1245
+ if (team) {
1246
+ try {
1247
+ await api.initiateApproval(team.team_id, 'delete_project', `delete project "${project.slug}"`, projectId);
1248
+ const approvals = await api.listApprovals(team.team_id);
1249
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1250
+ showToast('Approval requested', 'info');
1251
+ }
1252
+ catch {
1253
+ showToast('Failed to request approval', 'error');
1254
+ }
1255
+ break;
1256
+ }
1257
+ }
891
1258
  try {
892
1259
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting project' });
893
1260
  await api.deleteProject(projectId);
@@ -984,7 +1351,9 @@ export default function App() {
984
1351
  const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
985
1352
  try {
986
1353
  dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
987
- const project = await api.createProject(modal.input, slug);
1354
+ const project = modal.teamId
1355
+ ? await api.createTeamProject(modal.teamId, modal.input, slug)
1356
+ : await api.createProject(modal.input, slug);
988
1357
  await api.switchProject(project.id);
989
1358
  const me = await api.me();
990
1359
  dispatch({ type: 'SET_USER_DATA', me });
@@ -998,6 +1367,47 @@ export default function App() {
998
1367
  }
999
1368
  break;
1000
1369
  }
1370
+ case 'create-team': {
1371
+ if (!modal.input)
1372
+ break;
1373
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1374
+ try {
1375
+ dispatch({ type: 'SET_OPERATION', operation: 'Creating team' });
1376
+ const team = await api.createTeam(modal.input, slug);
1377
+ dispatch({ type: 'CLOSE_MODAL' });
1378
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team-project', teamId: team.id, teamName: modal.input, input: '' } });
1379
+ dispatch({ type: 'SET_OPERATION', operation: null });
1380
+ return true; // chained to create-team-project — caller must not CLOSE_MODAL
1381
+ }
1382
+ catch {
1383
+ dispatch({ type: 'SET_OPERATION', operation: null });
1384
+ showToast('Failed to create team', 'error');
1385
+ }
1386
+ break;
1387
+ }
1388
+ case 'create-team-project': {
1389
+ if (!modal.input)
1390
+ break;
1391
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1392
+ try {
1393
+ dispatch({ type: 'SET_OPERATION', operation: 'Creating project' });
1394
+ const project = await api.createTeamProject(modal.teamId, modal.input, slug);
1395
+ await api.switchProject(project.id);
1396
+ const me = await api.me();
1397
+ dispatch({ type: 'SET_USER_DATA', me });
1398
+ dispatch({ type: 'SET_OPERATION', operation: null });
1399
+ const newProjectId = me.user.active_project_id;
1400
+ if (newProjectId)
1401
+ connectFeed(newProjectId);
1402
+ await loadTabData();
1403
+ showToast('Team created', 'success');
1404
+ }
1405
+ catch {
1406
+ dispatch({ type: 'SET_OPERATION', operation: null });
1407
+ showToast('Failed to create project', 'error');
1408
+ }
1409
+ break;
1410
+ }
1001
1411
  case 'confirm-regen-link': {
1002
1412
  const teamForRegen = getActiveTeam(s);
1003
1413
  if (teamForRegen) {
@@ -1016,7 +1426,72 @@ export default function App() {
1016
1426
  }
1017
1427
  break;
1018
1428
  }
1429
+ case 'confirm-rename-team': {
1430
+ if (!modal.input)
1431
+ break;
1432
+ const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1433
+ const teamForRename = getActiveTeam(s);
1434
+ if (teamForRename) {
1435
+ try {
1436
+ dispatch({ type: 'SET_OPERATION', operation: 'Renaming team' });
1437
+ await api.renameTeam(teamForRename.team_id, modal.input, slug);
1438
+ const me = await api.me();
1439
+ dispatch({ type: 'SET_USER_DATA', me });
1440
+ dispatch({ type: 'SET_OPERATION', operation: null });
1441
+ showToast('Team renamed', 'success');
1442
+ }
1443
+ catch {
1444
+ dispatch({ type: 'SET_OPERATION', operation: null });
1445
+ showToast('Failed to rename team', 'error');
1446
+ }
1447
+ }
1448
+ break;
1449
+ }
1450
+ case 'confirm-delete-team': {
1451
+ const teamForDelete = getActiveTeam(s);
1452
+ if (teamForDelete && modal.input === modal.slug) {
1453
+ if (shouldRequireConsensus(s)) {
1454
+ const team = getActiveTeam(s);
1455
+ if (team) {
1456
+ try {
1457
+ await api.initiateApproval(team.team_id, 'delete_team', `delete team "${team.name}"`);
1458
+ const approvals = await api.listApprovals(team.team_id);
1459
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1460
+ showToast('Approval requested', 'info');
1461
+ }
1462
+ catch {
1463
+ showToast('Failed to request approval', 'error');
1464
+ }
1465
+ break;
1466
+ }
1467
+ }
1468
+ try {
1469
+ dispatch({ type: 'SET_OPERATION', operation: 'Deleting team' });
1470
+ await api.deleteTeam(teamForDelete.team_id);
1471
+ const me = await api.me();
1472
+ dispatch({ type: 'SET_USER_DATA', me });
1473
+ dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
1474
+ dispatch({ type: 'SET_OPERATION', operation: null });
1475
+ const newProjectId = me.user.active_project_id;
1476
+ if (newProjectId) {
1477
+ connectFeed(newProjectId);
1478
+ await loadTabData();
1479
+ }
1480
+ else {
1481
+ dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
1482
+ dispatch({ type: 'SET_MEMBERS', members: [] });
1483
+ }
1484
+ showToast('Team deleted', 'success');
1485
+ }
1486
+ catch {
1487
+ dispatch({ type: 'SET_OPERATION', operation: null });
1488
+ showToast('Failed to delete team', 'error');
1489
+ }
1490
+ }
1491
+ break;
1492
+ }
1019
1493
  }
1494
+ return false;
1020
1495
  }
1021
1496
  // ---- Small terminal guard ----
1022
1497
  if (cols < 80 || rows < 20) {
@@ -1025,7 +1500,7 @@ export default function App() {
1025
1500
  // ---- Determine content height ----
1026
1501
  // Header + divider + (banner + divider?) + tabbar + divider = top
1027
1502
  // toast? + operation? + divider + footer = bottom
1028
- const hasBanners = state.notifications.some(n => !n.seen) || hasBillingBanner(state) || state.firstLogin || !!state.joinedProjectName;
1503
+ 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());
1029
1504
  const topLines = 2 + (hasBanners ? 2 : 0) + 2; // header+div, (banner+div), tabbar+div
1030
1505
  const bottomLines = (state.toast ? 1 : 0) + (state.operationPending ? 1 : 0) + 2; // div + footer
1031
1506
  const contentHeight = Math.max(1, rows - topLines - bottomLines);