@polderlabs/bizar 4.8.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-DmpSFPJY.js +9 -0
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
- 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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/otel.mjs +133 -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/chat.mjs +246 -170
- 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/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- 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/server.mjs +40 -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/SettingsSearch.tsx +204 -89
- 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/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +166 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +22 -2
- package/bizar-dash/src/web/views/Settings.tsx +99 -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/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -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 +73 -0
- 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/settings-search.test.tsx +180 -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/docker-build.test.mjs +96 -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/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/otel.test.mjs +188 -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/dash.mjs +6 -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 -2
- 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-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/web/lib/search.ts — fuzzy search with typo tolerance (Levenshtein distance).
|
|
2
|
+
// Provides SearchableItem/SearchResult types and a fuzzySearch function for
|
|
3
|
+
// settings, commands, and any structured UI search.
|
|
4
|
+
|
|
5
|
+
export interface SearchableItem {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
section: string;
|
|
9
|
+
value?: string | number | boolean;
|
|
10
|
+
description?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchResult extends SearchableItem {
|
|
14
|
+
score: number;
|
|
15
|
+
matchedField: 'label' | 'value' | 'description';
|
|
16
|
+
matchedTerm: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Levenshtein distance for typo tolerance.
|
|
21
|
+
*/
|
|
22
|
+
export function levenshtein(a: string, b: string): number {
|
|
23
|
+
const alen = a.length;
|
|
24
|
+
const blen = b.length;
|
|
25
|
+
const matrix: number[][] = Array.from({ length: blen + 1 }, () => Array(alen + 1).fill(0));
|
|
26
|
+
for (let i = 0; i <= alen; i++) matrix[0][i] = i;
|
|
27
|
+
for (let j = 0; j <= blen; j++) matrix[j][0] = j;
|
|
28
|
+
for (let j = 1; j <= blen; j++) {
|
|
29
|
+
for (let i = 1; i <= alen; i++) {
|
|
30
|
+
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
31
|
+
matrix[j][i] = Math.min(
|
|
32
|
+
matrix[j][i - 1] + 1,
|
|
33
|
+
matrix[j - 1][i] + 1,
|
|
34
|
+
matrix[j - 1][i - 1] + indicator,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return matrix[blen][alen];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Score a single field against the query.
|
|
43
|
+
* Returns a numeric score (higher = better match) or 0 if no match.
|
|
44
|
+
*
|
|
45
|
+
* Scoring tiers (mutually exclusive — first match wins):
|
|
46
|
+
* 100 — exact field match (query === field)
|
|
47
|
+
* 90 — field starts with query (prefix match)
|
|
48
|
+
* 80 — a word in the field starts with query (word boundary)
|
|
49
|
+
* 40 — typo-tolerant match (Levenshtein ≤ 2 against the first query.length chars)
|
|
50
|
+
* 30 — substring match anywhere in field (fallback)
|
|
51
|
+
*/
|
|
52
|
+
export function scoreField(query: string, field: string): number {
|
|
53
|
+
// Exact match
|
|
54
|
+
if (field === query) return 100;
|
|
55
|
+
|
|
56
|
+
// Field prefix match (field starts with query)
|
|
57
|
+
if (field.startsWith(query)) return 90;
|
|
58
|
+
|
|
59
|
+
// Word boundary match (any word starts with query)
|
|
60
|
+
const words = field.split(/\s+/);
|
|
61
|
+
for (const word of words) {
|
|
62
|
+
if (word.startsWith(query)) return 80;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Typo tolerance: compare query against first query.length chars of field
|
|
66
|
+
const compareLen = Math.min(query.length, field.length);
|
|
67
|
+
const dist = levenshtein(query, field.substring(0, compareLen));
|
|
68
|
+
if (dist <= 2) return 40;
|
|
69
|
+
|
|
70
|
+
// Substring match (anywhere)
|
|
71
|
+
if (field.includes(query)) return 30;
|
|
72
|
+
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fuzzy search over an array of SearchableItem.
|
|
78
|
+
* Returns results sorted by descending score. Empty query returns [].
|
|
79
|
+
*/
|
|
80
|
+
export function fuzzySearch(query: string, items: SearchableItem[]): SearchResult[] {
|
|
81
|
+
const q = query.toLowerCase().trim();
|
|
82
|
+
if (!q) return [];
|
|
83
|
+
|
|
84
|
+
return items
|
|
85
|
+
.map((item) => {
|
|
86
|
+
const labelScore = scoreField(q, item.label.toLowerCase());
|
|
87
|
+
const valueScore = item.value ? scoreField(q, String(item.value).toLowerCase()) : 0;
|
|
88
|
+
const descScore = item.description ? scoreField(q, item.description.toLowerCase()) : 0;
|
|
89
|
+
|
|
90
|
+
const max = Math.max(labelScore, valueScore, descScore);
|
|
91
|
+
if (max === 0) return null;
|
|
92
|
+
|
|
93
|
+
const matchedField: SearchResult['matchedField'] =
|
|
94
|
+
labelScore >= valueScore && labelScore >= descScore
|
|
95
|
+
? 'label'
|
|
96
|
+
: valueScore >= labelScore && valueScore >= descScore
|
|
97
|
+
? 'value'
|
|
98
|
+
: 'description';
|
|
99
|
+
const matchedTerm =
|
|
100
|
+
matchedField === 'label'
|
|
101
|
+
? item.label
|
|
102
|
+
: matchedField === 'value'
|
|
103
|
+
? String(item.value)
|
|
104
|
+
: item.description!;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...item,
|
|
108
|
+
score: max,
|
|
109
|
+
matchedField,
|
|
110
|
+
matchedTerm,
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.filter((r): r is SearchResult => r !== null)
|
|
114
|
+
.sort((a, b) => b.score - a.score);
|
|
115
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/mobile/views/MobileSettings.tsx — mobile settings with full desktop parity.
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
+
import { Smartphone, Sun, Moon, Monitor, Save } from 'lucide-react';
|
|
5
4
|
import { api } from '../../lib/api';
|
|
6
5
|
import { applyTheme, applyThemeTokens, type Settings, type Snapshot, type ThemeName } from '../../lib/types';
|
|
6
|
+
import { QrCodePanel } from './QrCodePanel';
|
|
7
7
|
|
|
8
8
|
type Props = {
|
|
9
9
|
settings: Settings;
|
|
@@ -30,21 +30,12 @@ const PRESET_ACCENTS = [
|
|
|
30
30
|
{ name: 'Mono', accent: '#6b7280' },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
function formatCountdown(ms: number): string {
|
|
34
|
-
if (ms <= 0) return 'expired';
|
|
35
|
-
const s = Math.floor(ms / 1000);
|
|
36
|
-
const m = Math.floor(s / 60);
|
|
37
|
-
const r = s % 60;
|
|
38
|
-
return `${m}:${String(r).padStart(2, '0')}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props) {
|
|
42
34
|
const [settings, setSettings] = useState<Settings>(initial);
|
|
43
35
|
const [dirty, setDirty] = useState(false);
|
|
44
36
|
const [saving, setSaving] = useState(false);
|
|
45
37
|
const [pair, setPair] = useState<PairSession | null>(null);
|
|
46
38
|
const [pairing, setPairing] = useState(false);
|
|
47
|
-
const [now, setNow] = useState(Date.now());
|
|
48
39
|
|
|
49
40
|
useEffect(() => {
|
|
50
41
|
setSettings(initial);
|
|
@@ -52,12 +43,6 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
52
43
|
if (initial.theme) applyThemeTokens(initial.theme);
|
|
53
44
|
}, [initial]);
|
|
54
45
|
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (!pair) return;
|
|
57
|
-
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
58
|
-
return () => clearInterval(t);
|
|
59
|
-
}, [pair]);
|
|
60
|
-
|
|
61
46
|
const patchTheme = (patch: Partial<Settings['theme']>) => {
|
|
62
47
|
setSettings((cur) => {
|
|
63
48
|
const next = { ...cur, theme: { ...cur.theme, ...patch } };
|
|
@@ -101,8 +86,8 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
101
86
|
}
|
|
102
87
|
};
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
// remaining/expired are now managed inside QrCodePanel to keep
|
|
90
|
+
// qrcode.react out of the main mobile bundle chunk.
|
|
106
91
|
|
|
107
92
|
return (
|
|
108
93
|
<div className="mobile-view">
|
|
@@ -332,24 +317,14 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
332
317
|
<div className="mobile-card">
|
|
333
318
|
{!pair && (
|
|
334
319
|
<button type="button" className="mobile-btn" onClick={startPair} disabled={pairing} style={{ width: '100%' }}>
|
|
335
|
-
<
|
|
320
|
+
<Smartphone size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
|
|
336
321
|
</button>
|
|
337
322
|
)}
|
|
338
|
-
{pair &&
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
<div style={{ marginTop: 8, fontSize: 12 }}>
|
|
344
|
-
Expires in <strong>{formatCountdown(remaining)}</strong>
|
|
345
|
-
</div>
|
|
346
|
-
<div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
|
|
347
|
-
</div>
|
|
348
|
-
)}
|
|
349
|
-
{pair && expired && (
|
|
350
|
-
<button type="button" className="mobile-btn" onClick={startPair} style={{ width: '100%' }}>
|
|
351
|
-
<RefreshCw size={14} /> Generate new QR
|
|
352
|
-
</button>
|
|
323
|
+
{pair && (
|
|
324
|
+
<QrCodePanel
|
|
325
|
+
pair={pair}
|
|
326
|
+
onStart={startPair}
|
|
327
|
+
/>
|
|
353
328
|
)}
|
|
354
329
|
</div>
|
|
355
330
|
</section>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/mobile/views/QrCodePanel.tsx — lazy-loaded QR code panel.
|
|
2
|
+
// Split from MobileSettings to keep qrcode.react (~30 KB) out of the
|
|
3
|
+
// mobile entry chunk; it is only loaded when the user navigates to
|
|
4
|
+
// the Companion App section and taps "Generate QR Code".
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { RefreshCw } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
type PairSession = { token: string; qrPayload: string; publicUrl: string; expiresAt: number };
|
|
9
|
+
|
|
10
|
+
function formatCountdown(ms: number): string {
|
|
11
|
+
if (ms <= 0) return 'expired';
|
|
12
|
+
const s = Math.floor(ms / 1000);
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
const r = s % 60;
|
|
15
|
+
return `${m}:${String(r).padStart(2, '0')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function QrCodePanel({ pair, onStart }: {
|
|
19
|
+
pair: PairSession;
|
|
20
|
+
onStart: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
const [QRCodeSVG, setQRCodeSVG] = useState<React.ComponentType<{ value: string; size: number; level: string }> | null>(null);
|
|
23
|
+
const [remaining, setRemaining] = useState(() => pair.expiresAt - Date.now());
|
|
24
|
+
const expired = remaining <= 0;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
import('qrcode.react').then((mod) => {
|
|
28
|
+
setQRCodeSVG(mod.QRCodeSVG as React.ComponentType<{ value: string; size: number; level: string }>);
|
|
29
|
+
});
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!pair) return;
|
|
34
|
+
const tick = setInterval(() => {
|
|
35
|
+
setRemaining(pair.expiresAt - Date.now());
|
|
36
|
+
}, 1000);
|
|
37
|
+
return () => clearInterval(tick);
|
|
38
|
+
}, [pair]);
|
|
39
|
+
|
|
40
|
+
if (!QRCodeSVG) {
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ textAlign: 'center', padding: '16px' }}>
|
|
43
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>Loading QR library…</span>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ textAlign: 'center' }}>
|
|
50
|
+
<div style={{ background: '#fff', padding: 12, borderRadius: 12, display: 'inline-block' }}>
|
|
51
|
+
<QRCodeSVG value={pair.qrPayload} size={180} level="M" />
|
|
52
|
+
</div>
|
|
53
|
+
<div style={{ marginTop: 8, fontSize: 12 }}>
|
|
54
|
+
Expires in <strong>{formatCountdown(remaining)}</strong>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
|
|
57
|
+
{expired && (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="mobile-btn"
|
|
61
|
+
onClick={onStart}
|
|
62
|
+
style={{ marginTop: 12, width: '100%' }}
|
|
63
|
+
>
|
|
64
|
+
<RefreshCw size={14} /> Generate new QR
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -952,4 +952,169 @@ dl.memory-config-row dd {
|
|
|
952
952
|
|
|
953
953
|
.memory-status-card-sub {
|
|
954
954
|
font-size: 11px;
|
|
955
|
-
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* ── Memory Graph ─────────────────────────────────────────────────────── */
|
|
958
|
+
|
|
959
|
+
.memory-graph-canvas {
|
|
960
|
+
display: block;
|
|
961
|
+
background: var(--bg, #0b0e14);
|
|
962
|
+
border: 1px solid var(--border, #232a39);
|
|
963
|
+
border-radius: var(--radius, 8px);
|
|
964
|
+
user-select: none;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.memory-graph-canvas-svg {
|
|
968
|
+
height: 480px;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.memory-graph-node {
|
|
972
|
+
transition: opacity var(--motion-fast, 120ms);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.memory-graph-node:hover circle {
|
|
976
|
+
opacity: 1;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.memory-graph-tooltip {
|
|
980
|
+
position: absolute;
|
|
981
|
+
padding: 6px 10px;
|
|
982
|
+
background: var(--bg-elev, #12161f);
|
|
983
|
+
border: 1px solid var(--border, #232a39);
|
|
984
|
+
border-radius: var(--radius-sm, 6px);
|
|
985
|
+
font-size: 12px;
|
|
986
|
+
color: var(--text, #c9d1d9);
|
|
987
|
+
pointer-events: none;
|
|
988
|
+
z-index: 10;
|
|
989
|
+
max-width: 200px;
|
|
990
|
+
white-space: nowrap;
|
|
991
|
+
overflow: hidden;
|
|
992
|
+
text-overflow: ellipsis;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.memory-graph-controls {
|
|
996
|
+
display: flex;
|
|
997
|
+
flex-direction: column;
|
|
998
|
+
gap: 10px;
|
|
999
|
+
margin-top: 12px;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.memory-graph-search-row {
|
|
1003
|
+
display: flex;
|
|
1004
|
+
gap: 8px;
|
|
1005
|
+
align-items: center;
|
|
1006
|
+
flex-wrap: wrap;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.memory-graph-legend {
|
|
1010
|
+
display: flex;
|
|
1011
|
+
gap: 12px;
|
|
1012
|
+
flex-wrap: wrap;
|
|
1013
|
+
align-items: center;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.memory-graph-legend-item {
|
|
1017
|
+
display: flex;
|
|
1018
|
+
align-items: center;
|
|
1019
|
+
gap: 6px;
|
|
1020
|
+
font-size: 12px;
|
|
1021
|
+
color: var(--text-dim, #b4bcd0);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.memory-graph-legend-dot {
|
|
1025
|
+
display: inline-block;
|
|
1026
|
+
width: 10px;
|
|
1027
|
+
height: 10px;
|
|
1028
|
+
border-radius: 50%;
|
|
1029
|
+
flex-shrink: 0;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.memory-graph-stats {
|
|
1033
|
+
display: flex;
|
|
1034
|
+
gap: 8px;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
font-size: 13px;
|
|
1037
|
+
color: var(--text, #c9d1d9);
|
|
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
|
+
}
|
|
@@ -77,6 +77,86 @@
|
|
|
77
77
|
min-width: 0;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/* ─── Setting flash animation (quick-jump highlight) ─── */
|
|
81
|
+
.setting-flash {
|
|
82
|
+
animation: setting-flash 2s ease-out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@keyframes setting-flash {
|
|
86
|
+
0%, 100% { background-color: transparent; }
|
|
87
|
+
50% { background-color: var(--accent, rgba(139, 92, 246, 0.1)); border-radius: 6px; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Highlight matched terms in search results */
|
|
91
|
+
.settings-search-result-label mark {
|
|
92
|
+
background: var(--accent, rgba(139, 92, 246, 0.2));
|
|
93
|
+
color: inherit;
|
|
94
|
+
border-radius: 2px;
|
|
95
|
+
padding: 0 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Recent searches dropdown */
|
|
99
|
+
.settings-search-recent {
|
|
100
|
+
position: absolute;
|
|
101
|
+
top: calc(100% + 4px);
|
|
102
|
+
left: 0;
|
|
103
|
+
right: 0;
|
|
104
|
+
background: var(--bg-elev);
|
|
105
|
+
border: 1px solid var(--border);
|
|
106
|
+
border-radius: var(--radius-md);
|
|
107
|
+
box-shadow: var(--shadow-2);
|
|
108
|
+
z-index: 100;
|
|
109
|
+
padding: 4px;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: 2px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.settings-search-recent-header {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: space-between;
|
|
119
|
+
padding: 4px 8px 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.settings-search-recent-label {
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
text-transform: uppercase;
|
|
125
|
+
letter-spacing: 0.06em;
|
|
126
|
+
color: var(--text-dim);
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.settings-search-recent-clear {
|
|
131
|
+
font-size: 10px;
|
|
132
|
+
color: var(--accent-2);
|
|
133
|
+
background: none;
|
|
134
|
+
border: none;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
text-decoration: underline;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.settings-search-recent-item {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 6px;
|
|
143
|
+
padding: 5px 8px;
|
|
144
|
+
border-radius: var(--radius-sm);
|
|
145
|
+
border: none;
|
|
146
|
+
background: transparent;
|
|
147
|
+
color: var(--text-dim);
|
|
148
|
+
font-size: 12px;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
text-align: left;
|
|
151
|
+
width: 100%;
|
|
152
|
+
transition: background var(--motion-fast);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.settings-search-recent-item:hover {
|
|
156
|
+
background: var(--bg-elev-2);
|
|
157
|
+
color: var(--text);
|
|
158
|
+
}
|
|
159
|
+
|
|
80
160
|
/* Search within settings */
|
|
81
161
|
.settings-search {
|
|
82
162
|
position: relative;
|