@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.
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- 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,
|
|
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
|
+
}
|