@polderlabs/bizar 4.9.0 → 5.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 (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,158 @@
1
+ // src/web/components/WorkspaceSelector.tsx — v5.0.0 — dropdown to switch active workspace.
2
+ import { useState, useEffect, useRef } from 'react';
3
+ import { ChevronDown, Check, Plus, Users } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { cn } from '../lib/utils';
6
+ import { Button } from './Button';
7
+
8
+ export type WorkspaceInfo = {
9
+ id: string;
10
+ name: string;
11
+ role: string;
12
+ };
13
+
14
+ type Props = {
15
+ currentWorkspaceId: string | null;
16
+ onWorkspaceChange: (workspaceId: string) => void;
17
+ };
18
+
19
+ // Keep a global ref of workspaces so sibling components can refresh
20
+ let _cachedWorkspaces: WorkspaceInfo[] = [];
21
+ const _listeners = new Set<(ws: WorkspaceInfo[]) => void>();
22
+
23
+ function notifyListeners() {
24
+ _listeners.forEach((fn) => fn(_cachedWorkspaces));
25
+ }
26
+
27
+ export function useWorkspaceList() {
28
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>(_cachedWorkspaces);
29
+
30
+ useEffect(() => {
31
+ _listeners.add(setWorkspaces);
32
+ return () => { _listeners.delete(setWorkspaces); };
33
+ }, []);
34
+
35
+ return workspaces;
36
+ }
37
+
38
+ async function fetchWorkspaces(): Promise<WorkspaceInfo[]> {
39
+ try {
40
+ const r = await api.get<{ workspaces: Array<{ workspace: { id: string; name: string }; role: string }> }>('/workspaces');
41
+ const list = r.workspaces.map((w) => ({ id: w.workspace.id, name: w.workspace.name, role: w.role }));
42
+ _cachedWorkspaces = list;
43
+ notifyListeners();
44
+ return list;
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ export function WorkspaceSelector({ currentWorkspaceId, onWorkspaceChange }: Props) {
51
+ const [open, setOpen] = useState(false);
52
+ const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>(_cachedWorkspaces);
53
+ const [loading, setLoading] = useState(false);
54
+ const ref = useRef<HTMLDivElement>(null);
55
+
56
+ // Listen for global cache updates
57
+ useEffect(() => {
58
+ _listeners.add(setWorkspaces);
59
+ return () => { _listeners.delete(setWorkspaces); };
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ fetchWorkspaces().catch(() => undefined);
64
+ }, []);
65
+
66
+ // Close on outside click
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ function onOutside(e: MouseEvent) {
70
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
71
+ }
72
+ document.addEventListener('mousedown', onOutside);
73
+ return () => document.removeEventListener('mousedown', onOutside);
74
+ }, [open]);
75
+
76
+ const current = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
77
+ const canManage = current && (current.role === 'admin');
78
+
79
+ const handleSelect = async (wsId: string) => {
80
+ setOpen(false);
81
+ if (wsId === currentWorkspaceId) return;
82
+ onWorkspaceChange(wsId);
83
+ };
84
+
85
+ const handleRefresh = async () => {
86
+ setLoading(true);
87
+ await fetchWorkspaces();
88
+ setLoading(false);
89
+ };
90
+
91
+ return (
92
+ <div className="workspace-selector" ref={ref}>
93
+ <button
94
+ type="button"
95
+ className={cn('workspace-selector-trigger', open && 'workspace-selector-open')}
96
+ onClick={() => setOpen((v) => !v)}
97
+ aria-expanded={open}
98
+ title={current?.name || 'Select workspace'}
99
+ >
100
+ <Users size={14} />
101
+ <span className="workspace-selector-label">{current?.name || 'Workspace'}</span>
102
+ {canManage && <ChevronDown size={12} className={cn('workspace-selector-chevron', open && 'workspace-selector-chevron-open')} />}
103
+ </button>
104
+
105
+ {open && (
106
+ <div className="workspace-selector-dropdown" role="menu">
107
+ <div className="workspace-selector-header">
108
+ <span className="workspace-selector-title">Workspaces</span>
109
+ <Button
110
+ variant="ghost"
111
+ size="sm"
112
+ iconOnly
113
+ onClick={handleRefresh}
114
+ loading={loading}
115
+ title="Refresh workspaces"
116
+ >
117
+ <span style={{ transform: 'rotate(90deg)' }}>⟳</span>
118
+ </Button>
119
+ </div>
120
+
121
+ <div className="workspace-selector-list">
122
+ {workspaces.length === 0 && (
123
+ <div className="workspace-selector-empty">No workspaces</div>
124
+ )}
125
+ {workspaces.map((ws) => (
126
+ <button
127
+ key={ws.id}
128
+ type="button"
129
+ className={cn('workspace-selector-item', ws.id === currentWorkspaceId && 'workspace-selector-item-active')}
130
+ onClick={() => handleSelect(ws.id)}
131
+ role="menuitem"
132
+ >
133
+ <span className="workspace-selector-item-name">{ws.name}</span>
134
+ <span className={cn('workspace-selector-item-role', `role-${ws.role}`)}>{ws.role}</span>
135
+ {ws.id === currentWorkspaceId && <Check size={12} className="workspace-selector-item-check" />}
136
+ </button>
137
+ ))}
138
+ </div>
139
+
140
+ <div className="workspace-selector-footer">
141
+ <button
142
+ type="button"
143
+ className="workspace-selector-create"
144
+ onClick={() => {
145
+ setOpen(false);
146
+ // Trigger creation flow — emit a custom event that Workspace.tsx listens for
147
+ window.dispatchEvent(new CustomEvent('bizar:workspace:create'));
148
+ }}
149
+ >
150
+ <Plus size={12} />
151
+ New workspace
152
+ </button>
153
+ </div>
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -1036,3 +1036,85 @@ dl.memory-config-row dd {
1036
1036
  font-size: 13px;
1037
1037
  color: var(--text, #c9d1d9);
1038
1038
  }
1039
+
1040
+ /* ── Voice Notes ─────────────────────────────────────────────────────────── */
1041
+
1042
+ .voice-recorder {
1043
+ display: flex;
1044
+ align-items: center;
1045
+ gap: 8px;
1046
+ }
1047
+
1048
+ .voice-recorder-row {
1049
+ display: flex;
1050
+ align-items: center;
1051
+ gap: 8px;
1052
+ padding: 8px 0;
1053
+ }
1054
+
1055
+ .voice-note-list {
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: 6px;
1059
+ }
1060
+
1061
+ .voice-note-card {
1062
+ display: flex;
1063
+ flex-direction: column;
1064
+ gap: 6px;
1065
+ padding: 10px 12px;
1066
+ background: var(--bg-elev, #12161f);
1067
+ border: 1px solid var(--border, #232a39);
1068
+ border-radius: var(--radius, 8px);
1069
+ transition: border-color var(--motion-fast, 120ms);
1070
+ }
1071
+
1072
+ .voice-note-card:hover {
1073
+ border-color: var(--border-strong, #2d3648);
1074
+ }
1075
+
1076
+ .voice-note-card-head {
1077
+ display: flex;
1078
+ align-items: center;
1079
+ justify-content: space-between;
1080
+ gap: 8px;
1081
+ }
1082
+
1083
+ .voice-note-card-meta {
1084
+ display: flex;
1085
+ align-items: center;
1086
+ gap: 8px;
1087
+ font-size: 11px;
1088
+ color: var(--text-dim, #b4bcd0);
1089
+ }
1090
+
1091
+ .voice-note-date {
1092
+ font-family: var(--font-mono, monospace);
1093
+ }
1094
+
1095
+ .voice-note-duration {
1096
+ font-family: var(--font-mono, monospace);
1097
+ padding: 1px 6px;
1098
+ background: var(--bg-elev-2, #1a1f2b);
1099
+ border-radius: 4px;
1100
+ }
1101
+
1102
+ .voice-note-card-actions {
1103
+ display: flex;
1104
+ gap: 4px;
1105
+ flex-shrink: 0;
1106
+ }
1107
+
1108
+ .voice-note-transcript {
1109
+ margin: 0;
1110
+ font-size: 13px;
1111
+ color: var(--text, #c9d1d9);
1112
+ white-space: pre-wrap;
1113
+ word-break: break-word;
1114
+ line-height: 1.5;
1115
+ }
1116
+
1117
+ .voice-note-no-transcript {
1118
+ margin: 0;
1119
+ font-style: italic;
1120
+ }
@@ -10,11 +10,14 @@
10
10
  import React, { useCallback, useState } from 'react';
11
11
  import {
12
12
  Brain,
13
+ Clipboard,
13
14
  FileText,
14
15
  GitBranch,
15
16
  LayoutDashboard,
17
+ Mic,
16
18
  Network,
17
19
  RefreshCw,
20
+ Scan,
18
21
  Search as SearchIcon,
19
22
  Sliders,
20
23
  } from 'lucide-react';
@@ -28,8 +31,11 @@ import { GitSyncPanel } from './memory/GitSyncPanel';
28
31
  import { SemanticSearchPanel } from './memory/SemanticSearchPanel';
29
32
  import { ConfigPanel } from './memory/ConfigPanel';
30
33
  import { MemoryGraphPanel } from './memory/MemoryGraphPanel';
34
+ import { FromScreenshotPanel } from './memory/FromScreenshotPanel';
35
+ import { VaultFromClipboardPanel } from './memory/VaultFromClipboardPanel';
36
+ import { VoiceNotesPanel } from '../components/VoiceNotesPanel';
31
37
 
32
- type SubPanel = 'overview' | 'lightrag' | 'obsidian' | 'git' | 'semantic' | 'config' | 'graph';
38
+ type SubPanel = 'overview' | 'lightrag' | 'obsidian' | 'git' | 'semantic' | 'config' | 'graph' | 'webclip' | 'screenshot' | 'voice';
33
39
 
34
40
  type Props = {
35
41
  snapshot: unknown;
@@ -51,6 +57,9 @@ const SOURCES: Array<{
51
57
  { id: 'semantic', label: 'Semantic Search', icon: SearchIcon },
52
58
  { id: 'config', label: 'Config', icon: Sliders },
53
59
  { id: 'graph', label: 'Memory Graph', icon: Network },
60
+ { id: 'webclip', label: 'Web Clip', icon: Clipboard },
61
+ { id: 'screenshot', label: 'Screenshot OCR', icon: Scan },
62
+ { id: 'voice', label: 'Voice Notes', icon: Mic },
54
63
  ];
55
64
 
56
65
  function MemoryInner(_props: Props) {
@@ -83,6 +92,12 @@ function MemoryInner(_props: Props) {
83
92
  return <ConfigPanel refreshKey={refreshKey} />;
84
93
  case 'graph':
85
94
  return <MemoryGraphPanel refreshKey={refreshKey} />;
95
+ case 'webclip':
96
+ return <VaultFromClipboardPanel refreshKey={refreshKey} />;
97
+ case 'screenshot':
98
+ return <FromScreenshotPanel refreshKey={refreshKey} />;
99
+ case 'voice':
100
+ return <VoiceNotesPanel refreshKey={refreshKey} />;
86
101
  default:
87
102
  return <Card>Unknown panel: {active}</Card>;
88
103
  }
@@ -97,7 +112,7 @@ function MemoryInner(_props: Props) {
97
112
  <Brain size={18} /> Memory
98
113
  </h2>
99
114
  <p className="view-subtitle">
100
- LightRAG, Obsidian vault, git sync, and semantic search — all in one place.
115
+ LightRAG, Obsidian vault, git sync, semantic search, web clips, and screenshot OCR — all in one place.
101
116
  </p>
102
117
  </div>
103
118
  <div className="view-actions">
@@ -23,6 +23,7 @@ import { ProvidersSection } from './settings/ProvidersSection';
23
23
  import { MemorySection } from './settings/MemorySection';
24
24
  import { SkillsSection } from './settings/SkillsSection';
25
25
  import { BackupSection } from './settings/BackupSection';
26
+ import { WorkspacesSection } from './settings/WorkspacesSection';
26
27
 
27
28
  type Props = {
28
29
  snapshot: Snapshot;
@@ -52,6 +53,7 @@ const SECTION_LINKS = [
52
53
  { id: 'memory', label: 'Memory' },
53
54
  { id: 'skills', label: 'Skills' },
54
55
  { id: 'backup', label: 'Backup' },
56
+ { id: 'workspaces', label: 'Workspaces' },
55
57
  ] as const;
56
58
 
57
59
  /* ─── Settings search sections metadata ─── */
@@ -264,6 +266,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
264
266
  {(showAll || sectionOf('memory')) && <MemorySection />}
265
267
  {(showAll || sectionOf('skills')) && <SkillsSection />}
266
268
  {(showAll || sectionOf('backup')) && <BackupSection />}
269
+ {(showAll || sectionOf('workspaces')) && <WorkspacesSection />}
267
270
  </div>
268
271
  </div>
269
272
  );
@@ -0,0 +1,294 @@
1
+ // src/web/views/Workspace.tsx — v5.0.0 — workspace management view.
2
+ import { useEffect, useState, useCallback } from 'react';
3
+ import { Users, Plus, Trash2, Shield, Crown, Eye } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { useModal } from '../components/Modal';
6
+ import { Button } from '../components/Button';
7
+ import { InviteDialog, type InviteInfo } from '../components/InviteDialog';
8
+ import { cn } from '../lib/utils';
9
+
10
+ export type WorkspaceMember = {
11
+ userId: string;
12
+ email: string;
13
+ name: string;
14
+ role: 'admin' | 'editor' | 'viewer';
15
+ joinedAt: string;
16
+ };
17
+
18
+ export type WorkspaceInfo = {
19
+ id: string;
20
+ name: string;
21
+ ownerId: string;
22
+ createdAt: string;
23
+ };
24
+
25
+ type Props = {
26
+ workspaceId: string;
27
+ onWorkspaceDeleted?: () => void;
28
+ onWorkspaceUpdated?: (name: string) => void;
29
+ };
30
+
31
+ const ROLE_ICONS = {
32
+ admin: Crown,
33
+ editor: Shield,
34
+ viewer: Eye,
35
+ };
36
+
37
+ const ROLE_COLORS = {
38
+ admin: 'role-admin',
39
+ editor: 'role-editor',
40
+ viewer: 'role-viewer',
41
+ };
42
+
43
+ export function WorkspaceView({ workspaceId, onWorkspaceDeleted, onWorkspaceUpdated }: Props) {
44
+ const modal = useModal();
45
+ const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
46
+ const [members, setMembers] = useState<WorkspaceMember[]>([]);
47
+ const [invites, setInvites] = useState<InviteInfo[]>([]);
48
+ const [loading, setLoading] = useState(true);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const [nameDraft, setNameDraft] = useState('');
51
+ const [nameEditing, setNameEditing] = useState(false);
52
+
53
+ const loadWorkspace = useCallback(async () => {
54
+ setLoading(true);
55
+ setError(null);
56
+ try {
57
+ const r = await api.get<{ workspace: WorkspaceInfo; members: WorkspaceMember[] }>(
58
+ `/workspaces/${workspaceId}`,
59
+ );
60
+ setWorkspace(r.workspace);
61
+ setMembers(r.members || []);
62
+ setNameDraft(r.workspace.name);
63
+ } catch (err: unknown) {
64
+ setError((err as Error).message || 'Failed to load workspace');
65
+ } finally {
66
+ setLoading(false);
67
+ }
68
+ }, [workspaceId]);
69
+
70
+ useEffect(() => {
71
+ loadWorkspace();
72
+ }, [loadWorkspace]);
73
+
74
+ const loadInvites = async () => {
75
+ try {
76
+ const r = await api.get<{ invites: InviteInfo[] }>(`/workspaces/${workspaceId}/invites`);
77
+ setInvites(r.invites || []);
78
+ } catch {
79
+ // Ignore
80
+ }
81
+ };
82
+
83
+ useEffect(() => {
84
+ loadInvites();
85
+ }, [workspaceId]);
86
+
87
+ const handleSaveName = async () => {
88
+ if (!nameDraft.trim() || !workspace) return;
89
+ try {
90
+ // Workspace rename would be a separate endpoint; for now just update locally
91
+ setWorkspace((w) => (w ? { ...w, name: nameDraft.trim() } : w));
92
+ onWorkspaceUpdated?.(nameDraft.trim());
93
+ setNameEditing(false);
94
+ } catch {
95
+ setError('Failed to update name');
96
+ }
97
+ };
98
+
99
+ const handleDeleteWorkspace = async () => {
100
+ if (!confirm(`Delete workspace "${workspace?.name}"? This cannot be undone.`)) return;
101
+ if (!confirm('Are you absolutely sure? All workspace data will be permanently deleted.')) return;
102
+ try {
103
+ await api.del(`/workspaces/${workspaceId}`);
104
+ onWorkspaceDeleted?.();
105
+ } catch (err: unknown) {
106
+ setError((err as Error).message || 'Failed to delete workspace');
107
+ }
108
+ };
109
+
110
+ const handleRemoveMember = async (userId: string) => {
111
+ if (!confirm('Remove this member from the workspace?')) return;
112
+ try {
113
+ await api.del(`/workspaces/${workspaceId}/members/${userId}`);
114
+ setMembers((prev) => prev.filter((m) => m.userId !== userId));
115
+ } catch (err: unknown) {
116
+ setError((err as Error).message || 'Failed to remove member');
117
+ }
118
+ };
119
+
120
+ const handleUpdateRole = async (userId: string, newRole: string) => {
121
+ try {
122
+ await api.post(`/workspaces/${workspaceId}/members/${userId}`, { role: newRole });
123
+ setMembers((prev) =>
124
+ prev.map((m) => (m.userId === userId ? { ...m, role: newRole as WorkspaceMember['role'] } : m)),
125
+ );
126
+ } catch (err: unknown) {
127
+ setError((err as Error).message || 'Failed to update role');
128
+ }
129
+ };
130
+
131
+ // Note: In a real implementation, we'd get the current user ID from the session/JWT.
132
+ // For now, we trust the server to only render this view for authorized users.
133
+ const currentUserIsAdmin = true;
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="view view-workspace">
138
+ <div className="workspace-loading">Loading workspace...</div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ if (error && !workspace) {
144
+ return (
145
+ <div className="view view-workspace">
146
+ <div className="workspace-error">
147
+ <p>{error}</p>
148
+ <Button variant="secondary" size="sm" onClick={loadWorkspace}>
149
+ Retry
150
+ </Button>
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <div className="view view-workspace">
158
+ <header className="view-header">
159
+ <div className="view-header-text">
160
+ <h2 className="view-title">
161
+ <Users size={18} />
162
+ {nameEditing ? (
163
+ <input
164
+ type="text"
165
+ className="workspace-name-input"
166
+ value={nameDraft}
167
+ onChange={(e) => setNameDraft(e.target.value)}
168
+ onBlur={handleSaveName}
169
+ onKeyDown={(e) => {
170
+ if (e.key === 'Enter') handleSaveName();
171
+ if (e.key === 'Escape') setNameEditing(false);
172
+ }}
173
+ autoFocus
174
+ />
175
+ ) : (
176
+ <span onClick={() => currentUserIsAdmin && setNameEditing(true)} className={cn('workspace-name', currentUserIsAdmin && 'workspace-name-editable')}>
177
+ {workspace?.name}
178
+ </span>
179
+ )}
180
+ </h2>
181
+ <p className="view-subtitle">
182
+ Created {workspace?.createdAt ? new Date(workspace.createdAt).toLocaleDateString() : '—'}
183
+ </p>
184
+ </div>
185
+ <div className="view-actions">
186
+ {currentUserIsAdmin && (
187
+ <>
188
+ <Button
189
+ variant="secondary"
190
+ size="sm"
191
+ onClick={() => {
192
+ modal.open({
193
+ title: 'Invite Members',
194
+ children: <InviteDialog workspaceId={workspaceId} onInviteCreated={loadInvites} />,
195
+ width: 480,
196
+ });
197
+ }}
198
+ >
199
+ <Plus size={14} />
200
+ Invite
201
+ </Button>
202
+ <Button variant="danger" size="sm" onClick={handleDeleteWorkspace}>
203
+ <Trash2 size={14} />
204
+ Delete
205
+ </Button>
206
+ </>
207
+ )}
208
+ </div>
209
+ </header>
210
+
211
+ {error && (
212
+ <div className="workspace-error-banner">
213
+ <span>{error}</span>
214
+ <button onClick={() => setError(null)}>×</button>
215
+ </div>
216
+ )}
217
+
218
+ <div className="workspace-sections">
219
+ {/* Members section */}
220
+ <section className="workspace-section">
221
+ <h3 className="workspace-section-title">Members ({members.length})</h3>
222
+ <div className="workspace-members-list">
223
+ {members.map((member) => {
224
+ const RoleIcon = ROLE_ICONS[member.role] || Eye;
225
+ return (
226
+ <div key={member.userId} className="workspace-member">
227
+ <div className="workspace-member-info">
228
+ <div className="workspace-member-avatar">
229
+ {member.name?.[0]?.toUpperCase() || member.email[0].toUpperCase()}
230
+ </div>
231
+ <div className="workspace-member-details">
232
+ <span className="workspace-member-name">{member.name || 'Unknown'}</span>
233
+ <span className="workspace-member-email">{member.email}</span>
234
+ </div>
235
+ </div>
236
+ <div className="workspace-member-actions">
237
+ <span className={cn('workspace-member-role', ROLE_COLORS[member.role])}>
238
+ <RoleIcon size={12} />
239
+ {member.role}
240
+ </span>
241
+ {currentUserIsAdmin && member.role !== 'admin' && (
242
+ <>
243
+ <select
244
+ className="workspace-role-select"
245
+ value={member.role}
246
+ onChange={(e) => handleUpdateRole(member.userId, e.target.value)}
247
+ >
248
+ <option value="viewer">Viewer</option>
249
+ <option value="editor">Editor</option>
250
+ <option value="admin">Admin</option>
251
+ </select>
252
+ <Button
253
+ variant="ghost"
254
+ size="sm"
255
+ iconOnly
256
+ onClick={() => handleRemoveMember(member.userId)}
257
+ title="Remove member"
258
+ >
259
+ <Trash2 size={13} />
260
+ </Button>
261
+ </>
262
+ )}
263
+ </div>
264
+ </div>
265
+ );
266
+ })}
267
+ </div>
268
+ </section>
269
+
270
+ {/* Pending invites section */}
271
+ {currentUserIsAdmin && (
272
+ <section className="workspace-section">
273
+ <h3 className="workspace-section-title">Pending Invites ({invites.length})</h3>
274
+ {invites.length === 0 ? (
275
+ <p className="workspace-empty">No pending invites</p>
276
+ ) : (
277
+ <div className="workspace-invites-list">
278
+ {invites.map((inv) => (
279
+ <div key={inv.token} className="workspace-invite">
280
+ <span className="workspace-invite-email">{inv.email}</span>
281
+ <span className={cn('workspace-invite-role', ROLE_COLORS[inv.role])}>{inv.role}</span>
282
+ <span className="workspace-invite-expiry">
283
+ Expires {new Date(inv.expiresAt).toLocaleDateString()}
284
+ </span>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ )}
289
+ </section>
290
+ )}
291
+ </div>
292
+ </div>
293
+ );
294
+ }
@@ -0,0 +1,23 @@
1
+ // src/web/views/memory/FromScreenshotPanel.tsx — v5.0.0
2
+ //
3
+ // Memory tab panel that wraps ScreenshotOCR for the three-column Memory layout.
4
+ // Provides a self-contained screenshot → OCR → save workflow.
5
+
6
+ import { ScreenshotOCR } from '../../components/ScreenshotOCR';
7
+ import { Card } from '../../components/Card';
8
+
9
+ type Props = {
10
+ refreshKey: number;
11
+ };
12
+
13
+ export function FromScreenshotPanel({ refreshKey: _refreshKey }: Props) {
14
+ return (
15
+ <div className="memory-panel-content">
16
+ <Card variant="outlined" className="memory-panel-card">
17
+ <div className="memory-panel-body">
18
+ <ScreenshotOCR />
19
+ </div>
20
+ </Card>
21
+ </div>
22
+ );
23
+ }