@archznn/crewloop-skills 0.5.0 → 0.7.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/README.md +4 -16
- package/package.json +3 -3
- package/packages/cli/dist/agents.js +1 -1
- package/packages/cli/dist/agents.js.map +1 -1
- package/packages/cli/dist/cli.d.ts.map +1 -1
- package/packages/cli/dist/cli.js +31 -37
- package/packages/cli/dist/cli.js.map +1 -1
- package/packages/cli/dist/hooks.d.ts +6 -4
- package/packages/cli/dist/hooks.d.ts.map +1 -1
- package/packages/cli/dist/hooks.js +258 -98
- package/packages/cli/dist/hooks.js.map +1 -1
- package/packages/cli/dist/tests/cli.test.js +21 -0
- package/packages/cli/dist/tests/cli.test.js.map +1 -1
- package/packages/cli/dist/tests/hooks.test.js +253 -27
- package/packages/cli/dist/tests/hooks.test.js.map +1 -1
- package/references/conventions.md +1 -10
- package/references/workflow.md +1 -1
- package/servers/dashboard/README.md +55 -1
- package/servers/dashboard/bin/crewloop-shim.js +4 -0
- package/servers/dashboard/dist/adapters/agy.d.ts +19 -0
- package/servers/dashboard/dist/adapters/agy.d.ts.map +1 -0
- package/servers/dashboard/dist/adapters/agy.js +108 -0
- package/servers/dashboard/dist/adapters/agy.js.map +1 -0
- package/servers/dashboard/dist/adapters/codex.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/codex.js +2 -0
- package/servers/dashboard/dist/adapters/codex.js.map +1 -1
- package/servers/dashboard/dist/adapters/kimi.d.ts +1 -1
- package/servers/dashboard/dist/adapters/kimi.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/kimi.js +9 -0
- package/servers/dashboard/dist/adapters/kimi.js.map +1 -1
- package/servers/dashboard/dist/adapters/shim.d.ts +1 -1
- package/servers/dashboard/dist/adapters/shim.d.ts.map +1 -1
- package/servers/dashboard/dist/adapters/shim.js +32 -11
- package/servers/dashboard/dist/adapters/shim.js.map +1 -1
- package/servers/dashboard/dist/adapters/shim.test.js +46 -4
- package/servers/dashboard/dist/adapters/shim.test.js.map +1 -1
- package/servers/dashboard/dist/lib/constants.d.ts +5 -0
- package/servers/dashboard/dist/lib/constants.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/constants.js +46 -0
- package/servers/dashboard/dist/lib/constants.js.map +1 -0
- package/servers/dashboard/dist/lib/format.d.ts +6 -0
- package/servers/dashboard/dist/lib/format.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/format.js +52 -0
- package/servers/dashboard/dist/lib/format.js.map +1 -0
- package/servers/dashboard/dist/lib/graph.d.ts +22 -0
- package/servers/dashboard/dist/lib/graph.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/graph.js +45 -0
- package/servers/dashboard/dist/lib/graph.js.map +1 -0
- package/servers/dashboard/dist/lib/invocations.d.ts +32 -0
- package/servers/dashboard/dist/lib/invocations.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/invocations.js +135 -0
- package/servers/dashboard/dist/lib/invocations.js.map +1 -0
- package/servers/dashboard/dist/lib/invocations.test.d.ts +2 -0
- package/servers/dashboard/dist/lib/invocations.test.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/invocations.test.js +68 -0
- package/servers/dashboard/dist/lib/invocations.test.js.map +1 -0
- package/servers/dashboard/dist/lib/paths.d.ts +2 -0
- package/servers/dashboard/dist/lib/paths.d.ts.map +1 -0
- package/servers/dashboard/dist/lib/paths.js +40 -0
- package/servers/dashboard/dist/lib/paths.js.map +1 -0
- package/servers/dashboard/dist/presenter.d.ts.map +1 -1
- package/servers/dashboard/dist/presenter.js +2 -0
- package/servers/dashboard/dist/presenter.js.map +1 -1
- package/servers/dashboard/dist/public/assets/index-DjmMKbPN.css +1 -0
- package/servers/dashboard/dist/public/assets/index-DzOqMleZ.js +5323 -0
- package/servers/dashboard/dist/public/assets/index-DzOqMleZ.js.map +1 -0
- package/servers/dashboard/dist/public/index.html +16 -0
- package/servers/dashboard/dist/server.d.ts.map +1 -1
- package/servers/dashboard/dist/server.js +5 -1
- package/servers/dashboard/dist/server.js.map +1 -1
- package/servers/dashboard/dist/skills/infer.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/infer.js +0 -6
- package/servers/dashboard/dist/skills/infer.js.map +1 -1
- package/servers/dashboard/dist/skills/infer.test.js +10 -3
- package/servers/dashboard/dist/skills/infer.test.js.map +1 -1
- package/servers/dashboard/dist/skills/mapping.d.ts +0 -3
- package/servers/dashboard/dist/skills/mapping.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/mapping.js +0 -18
- package/servers/dashboard/dist/skills/mapping.js.map +1 -1
- package/servers/dashboard/dist/skills/registry.d.ts.map +1 -1
- package/servers/dashboard/dist/skills/registry.js +0 -1
- package/servers/dashboard/dist/skills/registry.js.map +1 -1
- package/servers/dashboard/dist/tests/adapters.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/adapters.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/adapters.test.js +180 -0
- package/servers/dashboard/dist/tests/adapters.test.js.map +1 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.js +123 -0
- package/servers/dashboard/dist/tests/lib-helpers.test.js.map +1 -0
- package/servers/dashboard/dist/tests/shim.test.d.ts +2 -0
- package/servers/dashboard/dist/tests/shim.test.d.ts.map +1 -0
- package/servers/dashboard/dist/tests/shim.test.js +133 -0
- package/servers/dashboard/dist/tests/shim.test.js.map +1 -0
- package/servers/dashboard/dist/types.d.ts +5 -2
- package/servers/dashboard/dist/types.d.ts.map +1 -1
- package/servers/dashboard/package.json +24 -6
- package/servers/dashboard/src/adapters/agy.ts +136 -0
- package/servers/dashboard/src/adapters/codex.ts +2 -0
- package/servers/dashboard/src/adapters/kimi.ts +11 -1
- package/servers/dashboard/src/adapters/shim.test.ts +57 -4
- package/servers/dashboard/src/adapters/shim.ts +31 -11
- package/servers/dashboard/src/lib/constants.ts +44 -0
- package/servers/dashboard/src/lib/format.ts +44 -0
- package/servers/dashboard/src/lib/graph.ts +69 -0
- package/servers/dashboard/src/lib/invocations.test.ts +70 -0
- package/servers/dashboard/src/lib/invocations.ts +172 -0
- package/servers/dashboard/src/lib/paths.ts +35 -0
- package/servers/dashboard/src/presenter.ts +2 -0
- package/servers/dashboard/src/server.ts +5 -1
- package/servers/dashboard/src/skills/infer.test.ts +11 -3
- package/servers/dashboard/src/skills/infer.ts +1 -8
- package/servers/dashboard/src/skills/mapping.ts +0 -20
- package/servers/dashboard/src/skills/registry.ts +0 -1
- package/servers/dashboard/src/tests/adapters.test.ts +198 -0
- package/servers/dashboard/src/tests/lib-helpers.test.ts +133 -0
- package/servers/dashboard/src/tests/shim.test.ts +153 -0
- package/servers/dashboard/src/types.ts +5 -3
- package/servers/dashboard/ui/index.html +15 -0
- package/servers/dashboard/ui/postcss.config.js +6 -0
- package/servers/dashboard/ui/src/App.tsx +360 -0
- package/servers/dashboard/ui/src/components/ActiveSkillPanel.tsx +69 -0
- package/servers/dashboard/ui/src/components/ActivityGraph.tsx +74 -0
- package/servers/dashboard/ui/src/components/CommandPalette.tsx +200 -0
- package/servers/dashboard/ui/src/components/FileActivity.tsx +20 -0
- package/servers/dashboard/ui/src/components/FileDiff.tsx +68 -0
- package/servers/dashboard/ui/src/components/FileList.tsx +64 -0
- package/servers/dashboard/ui/src/components/FilterBar.tsx +208 -0
- package/servers/dashboard/ui/src/components/Network3D.tsx +178 -0
- package/servers/dashboard/ui/src/components/SessionSelector.tsx +95 -0
- package/servers/dashboard/ui/src/components/Sidebar.tsx +110 -0
- package/servers/dashboard/ui/src/components/TelemetryPanel.tsx +57 -0
- package/servers/dashboard/ui/src/components/Timeline.tsx +57 -0
- package/servers/dashboard/ui/src/components/TimelineRow.tsx +112 -0
- package/servers/dashboard/ui/src/components/TopBar.tsx +116 -0
- package/servers/dashboard/ui/src/components/ViewHeader.tsx +19 -0
- package/servers/dashboard/ui/src/components/ui/Icon.tsx +105 -0
- package/servers/dashboard/ui/src/components/ui/StatusBadge.tsx +19 -0
- package/servers/dashboard/ui/src/components/views/FilesView.tsx +23 -0
- package/servers/dashboard/ui/src/components/views/NetworkView.tsx +20 -0
- package/servers/dashboard/ui/src/components/views/Overview.tsx +135 -0
- package/servers/dashboard/ui/src/components/views/SessionsView.tsx +84 -0
- package/servers/dashboard/ui/src/components/views/SettingsView.tsx +138 -0
- package/servers/dashboard/ui/src/components/views/SkillsView.tsx +92 -0
- package/servers/dashboard/ui/src/components/views/TimelineView.tsx +46 -0
- package/servers/dashboard/ui/src/contexts/FilterContext.tsx +41 -0
- package/servers/dashboard/ui/src/contexts/PinnedSessionsContext.tsx +80 -0
- package/servers/dashboard/ui/src/contexts/SettingsContext.tsx +60 -0
- package/servers/dashboard/ui/src/hooks/useCommandPalette.ts +36 -0
- package/servers/dashboard/ui/src/hooks/useKeyboardShortcut.ts +38 -0
- package/servers/dashboard/ui/src/hooks/useNow.ts +12 -0
- package/servers/dashboard/ui/src/hooks/useReducedMotion.ts +15 -0
- package/servers/dashboard/ui/src/hooks/useSessions.ts +64 -0
- package/servers/dashboard/ui/src/hooks/useTheme.ts +30 -0
- package/servers/dashboard/ui/src/hooks/useViewport.ts +19 -0
- package/servers/dashboard/ui/src/hooks/useWebSocket.ts +118 -0
- package/servers/dashboard/ui/src/lib/export.test.ts +33 -0
- package/servers/dashboard/ui/src/lib/export.ts +39 -0
- package/servers/dashboard/ui/src/lib/filter.test.ts +95 -0
- package/servers/dashboard/ui/src/lib/filter.ts +178 -0
- package/servers/dashboard/ui/src/lib/format.test.ts +25 -0
- package/servers/dashboard/ui/src/lib/search.test.ts +52 -0
- package/servers/dashboard/ui/src/lib/search.ts +60 -0
- package/servers/dashboard/ui/src/lib/settings.test.ts +50 -0
- package/servers/dashboard/ui/src/lib/settings.ts +56 -0
- package/servers/dashboard/ui/src/lib/types.ts +124 -0
- package/servers/dashboard/ui/src/main.tsx +19 -0
- package/servers/dashboard/ui/src/styles/index.css +155 -0
- package/servers/dashboard/ui/tailwind.config.js +45 -0
- package/servers/dashboard/ui/tsconfig.json +33 -0
- package/servers/dashboard/ui/tsconfig.node.json +10 -0
- package/servers/dashboard/ui/vite.config.ts +37 -0
- package/servers/dashboard/ui/vitest.config.ts +8 -0
- package/skills/accessibility-auditor/SKILL.md +0 -20
- package/skills/architect/SKILL.md +0 -45
- package/skills/designer/SKILL.md +0 -30
- package/skills/docs-writer/SKILL.md +0 -13
- package/skills/engineer/SKILL.md +0 -30
- package/skills/maintainer/SKILL.md +0 -20
- package/skills/orchestrator/SKILL.md +0 -13
- package/skills/product-manager/SKILL.md +0 -20
- package/skills/researcher/SKILL.md +0 -20
- package/skills/reviewer/SKILL.md +0 -30
- package/skills/security-guard/SKILL.md +0 -20
- package/skills/shipper/SKILL.md +0 -33
- package/skills/tester/SKILL.md +0 -20
- package/packages/cli/dist/mcp.d.ts +0 -28
- package/packages/cli/dist/mcp.d.ts.map +0 -1
- package/packages/cli/dist/mcp.js +0 -148
- package/packages/cli/dist/mcp.js.map +0 -1
- package/packages/cli/dist/tests/mcp.test.d.ts +0 -2
- package/packages/cli/dist/tests/mcp.test.d.ts.map +0 -1
- package/packages/cli/dist/tests/mcp.test.js +0 -232
- package/packages/cli/dist/tests/mcp.test.js.map +0 -1
- package/references/obsidian-mcp-usage.md +0 -190
- package/servers/dashboard/public/app.js +0 -516
- package/servers/dashboard/public/index.html +0 -96
- package/servers/dashboard/public/styles.css +0 -819
- package/servers/obsidian-mcp/README.md +0 -82
- package/servers/obsidian-mcp/pyproject.toml +0 -32
- package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/config.py +0 -47
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +0 -105
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +0 -79
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +0 -141
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +0 -37
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +0 -66
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +0 -40
- package/servers/obsidian-mcp/src/obsidian_mcp/main.py +0 -4
- package/servers/obsidian-mcp/src/obsidian_mcp/models.py +0 -42
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +0 -68
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +0 -50
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +0 -55
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +0 -37
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +0 -118
- package/servers/obsidian-mcp/src/obsidian_mcp/server.py +0 -61
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +0 -43
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +0 -16
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +0 -42
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +0 -16
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +0 -15
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +0 -130
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +0 -20
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +0 -26
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +0 -22
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +0 -34
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +0 -82
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +0 -68
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +0 -61
- package/servers/obsidian-mcp/tests/conftest.py +0 -39
- package/servers/obsidian-mcp/tests/test_async_tools.py +0 -87
- package/servers/obsidian-mcp/tests/test_edge_cases.py +0 -59
- package/servers/obsidian-mcp/tests/test_indexer.py +0 -27
- package/servers/obsidian-mcp/tests/test_integration.py +0 -90
- package/servers/obsidian-mcp/tests/test_learning.py +0 -34
- package/servers/obsidian-mcp/tests/test_privacy.py +0 -31
- package/servers/obsidian-mcp/tests/test_privacy_config.py +0 -44
- package/servers/obsidian-mcp/tests/test_rag.py +0 -64
- package/servers/obsidian-mcp/tests/test_read_raw.py +0 -37
- package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +0 -54
- package/servers/obsidian-mcp/tests/test_tools.py +0 -108
- package/servers/obsidian-mcp/tests/test_vault.py +0 -103
- package/servers/obsidian-mcp/tests/test_writer.py +0 -139
- package/skills/obsidian-second-brain/SKILL.md +0 -298
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FileEntry } from '../../../src/lib/invocations';
|
|
2
|
+
import { FileList } from './FileList';
|
|
3
|
+
import { FileDiff } from './FileDiff';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
files: FileEntry[];
|
|
7
|
+
selectedPath: string | null;
|
|
8
|
+
onSelect: (path: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FileActivity({ files, selectedPath, onSelect }: Props) {
|
|
12
|
+
const selectedFile = files.find((f) => f.path === selectedPath);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
16
|
+
<FileList files={files} selectedPath={selectedPath} onSelect={onSelect} />
|
|
17
|
+
<FileDiff file={selectedFile} />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { escapeHtml } from '../../../src/lib/format';
|
|
2
|
+
import type { FileEntry } from '../../../src/lib/invocations';
|
|
3
|
+
import { StatusBadge } from './ui/StatusBadge';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
file: FileEntry | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function FileDiff({ file }: Props) {
|
|
10
|
+
if (!file) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
|
|
13
|
+
Select a file to view activity.
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const latest = file.ops[file.ops.length - 1];
|
|
19
|
+
const lines = file.snippet ? String(file.snippet).split('\n').slice(0, 80) : [];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
|
23
|
+
<header className="flex flex-col gap-2 p-4 pb-3 border-b border-border-default">
|
|
24
|
+
<span className="text-text-primary break-all text-sm font-mono">{file.path}</span>
|
|
25
|
+
<div className="flex gap-2 flex-wrap">
|
|
26
|
+
{file.ops.map((op) => (
|
|
27
|
+
<span
|
|
28
|
+
key={op.id}
|
|
29
|
+
className={`text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded border ${
|
|
30
|
+
op.type === 'read'
|
|
31
|
+
? 'text-running border-running/35'
|
|
32
|
+
: op.type === 'edit'
|
|
33
|
+
? 'text-success border-success/35'
|
|
34
|
+
: 'text-text-muted border-border-default'
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
{op.type}
|
|
38
|
+
</span>
|
|
39
|
+
))}
|
|
40
|
+
<StatusBadge status={latest.status} />
|
|
41
|
+
</div>
|
|
42
|
+
</header>
|
|
43
|
+
<div className="flex-1 overflow-auto p-4 font-mono text-[13px]">
|
|
44
|
+
{lines.length === 0 ? (
|
|
45
|
+
<p className="text-text-muted">No diff or content snippet available.</p>
|
|
46
|
+
) : (
|
|
47
|
+
<div className="min-w-full inline-block">
|
|
48
|
+
{lines.map((line, i) => {
|
|
49
|
+
let cls = 'text-text-secondary px-1 whitespace-pre';
|
|
50
|
+
if (line.startsWith('+')) cls = 'text-success bg-success/5 px-1 whitespace-pre';
|
|
51
|
+
else if (line.startsWith('-')) cls = 'text-error bg-error/5 px-1 whitespace-pre';
|
|
52
|
+
else if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++'))
|
|
53
|
+
cls = 'text-text-muted px-1 whitespace-pre';
|
|
54
|
+
return (
|
|
55
|
+
<div key={i} className={cls}>
|
|
56
|
+
<span dangerouslySetInnerHTML={{ __html: escapeHtml(line) }} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
{String(file.snippet).split('\n').length > 80 && (
|
|
61
|
+
<p className="text-text-muted mt-2 text-xs">Content truncated.</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { FileEntry } from '../../../src/lib/invocations';
|
|
2
|
+
import { truncate } from '../../../src/lib/format';
|
|
3
|
+
import { Icon } from './ui/Icon';
|
|
4
|
+
import { StatusBadge } from './ui/StatusBadge';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
files: FileEntry[];
|
|
8
|
+
selectedPath: string | null;
|
|
9
|
+
onSelect: (path: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FileList({ files, selectedPath, onSelect }: Props) {
|
|
13
|
+
if (files.length === 0) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="w-full md:w-72 flex-shrink-0 flex items-center justify-center text-text-muted text-sm border-r border-border-default">
|
|
16
|
+
No file activity matches the filters.
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="w-full md:w-72 flex-shrink-0 overflow-y-auto border-r border-border-default p-2">
|
|
23
|
+
{files.map((file) => {
|
|
24
|
+
const latest = file.ops[file.ops.length - 1];
|
|
25
|
+
const isActive = file.path === selectedPath;
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
key={file.path}
|
|
29
|
+
onClick={() => onSelect(file.path)}
|
|
30
|
+
className={`file-list-item w-full flex flex-col gap-1.5 text-left p-2.5 rounded border transition-colors ${
|
|
31
|
+
isActive
|
|
32
|
+
? 'bg-elevated border-accent'
|
|
33
|
+
: 'border-transparent hover:bg-elevated'
|
|
34
|
+
}`}
|
|
35
|
+
>
|
|
36
|
+
<div className="flex items-center gap-2 min-w-0 text-text-primary font-mono text-[13px]">
|
|
37
|
+
<Icon name="FileText" className="w-4 h-4 text-accent flex-shrink-0" />
|
|
38
|
+
<span className="truncate" title={file.path}>
|
|
39
|
+
{truncate(file.path, 32)}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="flex gap-1.5 flex-wrap items-center">
|
|
43
|
+
{file.ops.map((op) => (
|
|
44
|
+
<span
|
|
45
|
+
key={op.id}
|
|
46
|
+
className={`text-[10px] font-semibold uppercase px-1.5 py-0.5 rounded border ${
|
|
47
|
+
op.type === 'read'
|
|
48
|
+
? 'text-running border-running/35'
|
|
49
|
+
: op.type === 'edit'
|
|
50
|
+
? 'text-success border-success/35'
|
|
51
|
+
: 'text-text-muted border-border-default'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
{op.type}
|
|
55
|
+
</span>
|
|
56
|
+
))}
|
|
57
|
+
<StatusBadge status={latest.status} />
|
|
58
|
+
</div>
|
|
59
|
+
</button>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { FilterOptions, TimeRange } from '../lib/types';
|
|
3
|
+
import { useFilters } from '../contexts/FilterContext';
|
|
4
|
+
import { Icon } from './ui/Icon';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
options: FilterOptions;
|
|
8
|
+
resultCount: number;
|
|
9
|
+
onExport?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function FilterPopover({
|
|
13
|
+
label,
|
|
14
|
+
activeCount,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
label: string;
|
|
18
|
+
activeCount: number;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
function onMouseDown(e: MouseEvent) {
|
|
27
|
+
if (!ref.current?.contains(e.target as Node)) {
|
|
28
|
+
setOpen(false);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
document.addEventListener('mousedown', onMouseDown);
|
|
32
|
+
return () => document.removeEventListener('mousedown', onMouseDown);
|
|
33
|
+
}, [open]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div ref={ref} className="relative">
|
|
37
|
+
<button
|
|
38
|
+
onClick={() => setOpen((v) => !v)}
|
|
39
|
+
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs transition-colors ${
|
|
40
|
+
activeCount > 0
|
|
41
|
+
? 'border-accent text-accent bg-accent/10'
|
|
42
|
+
: 'border-border-default text-text-secondary hover:border-strong'
|
|
43
|
+
}`}
|
|
44
|
+
>
|
|
45
|
+
<span>{label}</span>
|
|
46
|
+
{activeCount > 0 && (
|
|
47
|
+
<span className="px-1 rounded bg-accent text-white text-[10px]">{activeCount}</span>
|
|
48
|
+
)}
|
|
49
|
+
</button>
|
|
50
|
+
{open && (
|
|
51
|
+
<div className="absolute top-[calc(100%+6px)] left-0 z-30 min-w-[180px] max-h-64 overflow-y-auto bg-surface border border-border-default rounded-lg shadow-lg p-2">
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CheckboxRow({
|
|
60
|
+
label,
|
|
61
|
+
checked,
|
|
62
|
+
onChange,
|
|
63
|
+
}: {
|
|
64
|
+
label: string;
|
|
65
|
+
checked: boolean;
|
|
66
|
+
onChange: (checked: boolean) => void;
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<label className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-elevated cursor-pointer text-sm text-text-secondary">
|
|
70
|
+
<input
|
|
71
|
+
type="checkbox"
|
|
72
|
+
checked={checked}
|
|
73
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
74
|
+
className="accent-accent"
|
|
75
|
+
/>
|
|
76
|
+
<span className="truncate">{label}</span>
|
|
77
|
+
</label>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function FilterBar({ options, resultCount, onExport }: Props) {
|
|
82
|
+
const { filters, setFilters, resetFilters } = useFilters();
|
|
83
|
+
const activeCount =
|
|
84
|
+
filters.sources.length +
|
|
85
|
+
filters.skills.length +
|
|
86
|
+
filters.statuses.length +
|
|
87
|
+
filters.tools.length +
|
|
88
|
+
filters.opTypes.length +
|
|
89
|
+
(filters.timeRange !== 'all' ? 1 : 0);
|
|
90
|
+
|
|
91
|
+
function toggleList<T extends string>(current: T[], value: T): T[] {
|
|
92
|
+
return current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="filter-bar flex flex-col gap-2 px-5 py-3 border-b border-border-default flex-shrink-0">
|
|
97
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
98
|
+
<div className="relative flex items-center flex-1 min-w-[200px]">
|
|
99
|
+
<Icon name="MagnifyingGlass" className="absolute left-2.5 w-4 h-4 text-text-muted" />
|
|
100
|
+
<input
|
|
101
|
+
type="text"
|
|
102
|
+
value={filters.query}
|
|
103
|
+
onChange={(e) => setFilters({ query: e.target.value })}
|
|
104
|
+
placeholder="Filter events..."
|
|
105
|
+
className="w-full h-9 pl-9 pr-8 rounded-lg bg-elevated border border-border-default text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-accent"
|
|
106
|
+
/>
|
|
107
|
+
{filters.query && (
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => setFilters({ query: '' })}
|
|
110
|
+
aria-label="Clear search"
|
|
111
|
+
className="absolute right-2 text-text-muted hover:text-text-primary"
|
|
112
|
+
>
|
|
113
|
+
<Icon name="XCircle" className="w-4 h-4" />
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<FilterPopover label="Source" activeCount={filters.sources.length}>
|
|
119
|
+
{options.sources.map((s) => (
|
|
120
|
+
<CheckboxRow
|
|
121
|
+
key={s}
|
|
122
|
+
label={s}
|
|
123
|
+
checked={filters.sources.includes(s)}
|
|
124
|
+
onChange={() => setFilters({ sources: toggleList(filters.sources, s) })}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</FilterPopover>
|
|
128
|
+
|
|
129
|
+
<FilterPopover label="Skill" activeCount={filters.skills.length}>
|
|
130
|
+
{options.skills.map((s) => (
|
|
131
|
+
<CheckboxRow
|
|
132
|
+
key={s}
|
|
133
|
+
label={s}
|
|
134
|
+
checked={filters.skills.includes(s)}
|
|
135
|
+
onChange={() => setFilters({ skills: toggleList(filters.skills, s) })}
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</FilterPopover>
|
|
139
|
+
|
|
140
|
+
<FilterPopover label="Status" activeCount={filters.statuses.length}>
|
|
141
|
+
{options.statuses.map((s) => (
|
|
142
|
+
<CheckboxRow
|
|
143
|
+
key={s}
|
|
144
|
+
label={s}
|
|
145
|
+
checked={filters.statuses.includes(s)}
|
|
146
|
+
onChange={() => setFilters({ statuses: toggleList(filters.statuses, s) })}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</FilterPopover>
|
|
150
|
+
|
|
151
|
+
<FilterPopover label="Tool" activeCount={filters.tools.length}>
|
|
152
|
+
{options.tools.map((t) => (
|
|
153
|
+
<CheckboxRow
|
|
154
|
+
key={t}
|
|
155
|
+
label={t}
|
|
156
|
+
checked={filters.tools.includes(t)}
|
|
157
|
+
onChange={() => setFilters({ tools: toggleList(filters.tools, t) })}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</FilterPopover>
|
|
161
|
+
|
|
162
|
+
<FilterPopover label="Op" activeCount={filters.opTypes.length}>
|
|
163
|
+
{options.opTypes.map((o) => (
|
|
164
|
+
<CheckboxRow
|
|
165
|
+
key={o}
|
|
166
|
+
label={o}
|
|
167
|
+
checked={filters.opTypes.includes(o)}
|
|
168
|
+
onChange={() => setFilters({ opTypes: toggleList(filters.opTypes, o) })}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
</FilterPopover>
|
|
172
|
+
|
|
173
|
+
<FilterPopover label="Time" activeCount={filters.timeRange !== 'all' ? 1 : 0}>
|
|
174
|
+
{(['all', '1m', '5m', '15m', '1h', '24h', 'session'] as TimeRange[]).map((r) => (
|
|
175
|
+
<CheckboxRow
|
|
176
|
+
key={r}
|
|
177
|
+
label={r === 'all' ? 'All time' : r === 'session' ? 'This session' : r}
|
|
178
|
+
checked={filters.timeRange === r}
|
|
179
|
+
onChange={() => setFilters({ timeRange: r })}
|
|
180
|
+
/>
|
|
181
|
+
))}
|
|
182
|
+
</FilterPopover>
|
|
183
|
+
|
|
184
|
+
{activeCount > 0 && (
|
|
185
|
+
<button
|
|
186
|
+
onClick={resetFilters}
|
|
187
|
+
className="text-xs text-text-muted hover:text-text-primary transition-colors"
|
|
188
|
+
>
|
|
189
|
+
Clear all
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{onExport && (
|
|
194
|
+
<button
|
|
195
|
+
onClick={onExport}
|
|
196
|
+
aria-label="Export JSON"
|
|
197
|
+
className="w-9 h-9 rounded-lg border border-border-default bg-elevated text-text-secondary hover:border-accent hover:text-accent transition-colors flex items-center justify-center"
|
|
198
|
+
>
|
|
199
|
+
<Icon name="DownloadSimple" className="w-4 h-4" />
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
<div className="text-xs text-text-muted">
|
|
204
|
+
{resultCount} result{resultCount === 1 ? '' : 's'}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import ForceGraph3D from 'react-force-graph-3d';
|
|
3
|
+
import type { ForceGraphMethods } from 'react-force-graph-3d';
|
|
4
|
+
import type { Graph3D, GraphLink, GraphNode } from '../../../src/lib/graph';
|
|
5
|
+
import { TYPE_COLORS } from '../../../src/lib/constants';
|
|
6
|
+
import { useSettings } from '../contexts/SettingsContext';
|
|
7
|
+
import { Icon } from './ui/Icon';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
graph: Graph3D;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Network3D({ graph }: Props) {
|
|
14
|
+
const { resolvedTheme: theme, reducedMotion } = useSettings();
|
|
15
|
+
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
|
|
16
|
+
const [selected, setSelected] = useState<GraphNode | null>(null);
|
|
17
|
+
const [hovered, setHovered] = useState<GraphNode | null>(null);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const stableNodesRef = useRef<Map<string, GraphNode>>(new Map());
|
|
21
|
+
const stableLinksRef = useRef<Map<string, GraphLink>>(new Map());
|
|
22
|
+
|
|
23
|
+
const normalizedGraph = useMemo(() => {
|
|
24
|
+
const next = graph;
|
|
25
|
+
const nodeIds = new Set<string>();
|
|
26
|
+
const nodes: GraphNode[] = [];
|
|
27
|
+
for (const n of next.nodes) {
|
|
28
|
+
nodeIds.add(n.id);
|
|
29
|
+
const existing = stableNodesRef.current.get(n.id);
|
|
30
|
+
if (existing) {
|
|
31
|
+
existing.weight = n.weight;
|
|
32
|
+
existing.label = n.label;
|
|
33
|
+
existing.type = n.type;
|
|
34
|
+
nodes.push(existing);
|
|
35
|
+
} else {
|
|
36
|
+
stableNodesRef.current.set(n.id, n);
|
|
37
|
+
nodes.push(n);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const id of Array.from(stableNodesRef.current.keys())) {
|
|
41
|
+
if (!nodeIds.has(id)) stableNodesRef.current.delete(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const linkKeys = new Set<string>();
|
|
45
|
+
const links: GraphLink[] = [];
|
|
46
|
+
for (const l of next.links) {
|
|
47
|
+
const key = `${l.source}→${l.target}`;
|
|
48
|
+
linkKeys.add(key);
|
|
49
|
+
const existing = stableLinksRef.current.get(key);
|
|
50
|
+
if (existing) {
|
|
51
|
+
existing.weight = l.weight;
|
|
52
|
+
links.push(existing);
|
|
53
|
+
} else {
|
|
54
|
+
stableLinksRef.current.set(key, l);
|
|
55
|
+
links.push(l);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const key of Array.from(stableLinksRef.current.keys())) {
|
|
59
|
+
if (!linkKeys.has(key)) stableLinksRef.current.delete(key);
|
|
60
|
+
}
|
|
61
|
+
return { nodes, links };
|
|
62
|
+
}, [graph]);
|
|
63
|
+
|
|
64
|
+
const colors = useMemo(() => {
|
|
65
|
+
const root = getComputedStyle(document.documentElement);
|
|
66
|
+
const resolve = (token: string) => {
|
|
67
|
+
const raw = TYPE_COLORS[token];
|
|
68
|
+
if (!raw.startsWith('var(')) return raw;
|
|
69
|
+
const name = raw.slice(4, -1).trim();
|
|
70
|
+
return root.getPropertyValue(name).trim() || '#888888';
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
skill: resolve('skill'),
|
|
74
|
+
tool: resolve('tool'),
|
|
75
|
+
file: resolve('file'),
|
|
76
|
+
link: '#3f3f46',
|
|
77
|
+
};
|
|
78
|
+
}, [theme]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
try {
|
|
82
|
+
const canvas = document.createElement('canvas');
|
|
83
|
+
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
84
|
+
if (!gl) setError('WebGL is not available in this browser.');
|
|
85
|
+
} catch {
|
|
86
|
+
setError('Could not initialize WebGL.');
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!fgRef.current || normalizedGraph.nodes.length === 0) return;
|
|
92
|
+
const controls = (fgRef.current as unknown as { zoomToFit?: (duration: number) => void }).zoomToFit;
|
|
93
|
+
if (controls) controls(reducedMotion ? 0 : 400);
|
|
94
|
+
}, [normalizedGraph, reducedMotion]);
|
|
95
|
+
|
|
96
|
+
if (error) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-muted">
|
|
99
|
+
<Icon name="Monitor" className="w-10 h-10" />
|
|
100
|
+
<p>{error}</p>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (normalizedGraph.nodes.length === 0) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex-1 flex items-center justify-center text-text-muted">
|
|
108
|
+
Waiting for agent activity...
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const linkSourceId = (l: GraphLink) => (typeof l.source === 'string' ? l.source : (l.source as GraphNode).id);
|
|
114
|
+
const linkTargetId = (l: GraphLink) => (typeof l.target === 'string' ? l.target : (l.target as GraphNode).id);
|
|
115
|
+
|
|
116
|
+
const connected = selected
|
|
117
|
+
? normalizedGraph.links.filter((l) => linkSourceId(l) === selected.id || linkTargetId(l) === selected.id)
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex-1 relative min-h-0">
|
|
122
|
+
<ForceGraph3D
|
|
123
|
+
ref={fgRef}
|
|
124
|
+
graphData={normalizedGraph}
|
|
125
|
+
nodeColor={(n) => colors[(n as GraphNode).type] || colors.file}
|
|
126
|
+
nodeRelSize={4}
|
|
127
|
+
nodeVal={(n) => Math.max(1, (n as GraphNode).weight || 1)}
|
|
128
|
+
nodeLabel="label"
|
|
129
|
+
linkWidth={1}
|
|
130
|
+
linkColor={() => colors.link}
|
|
131
|
+
backgroundColor="rgba(0,0,0,0)"
|
|
132
|
+
showNavInfo={false}
|
|
133
|
+
enableNavigationControls
|
|
134
|
+
onNodeHover={(node) => setHovered(node ? (node as GraphNode) : null)}
|
|
135
|
+
onNodeClick={(node) => {
|
|
136
|
+
const n = node as GraphNode;
|
|
137
|
+
setSelected(n);
|
|
138
|
+
if (fgRef.current && n.x != null && n.y != null && n.z != null) {
|
|
139
|
+
const coords = n as unknown as { x: number; y: number; z: number };
|
|
140
|
+
fgRef.current.cameraPosition(
|
|
141
|
+
{ x: coords.x + 40, y: coords.y + 40, z: coords.z + 40 },
|
|
142
|
+
coords,
|
|
143
|
+
reducedMotion ? 0 : 400
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}}
|
|
147
|
+
onEngineStop={() => {
|
|
148
|
+
const controls = (fgRef.current as unknown as { zoomToFit?: (duration: number) => void })?.zoomToFit;
|
|
149
|
+
if (controls) controls(reducedMotion ? 0 : 400);
|
|
150
|
+
}}
|
|
151
|
+
warmupTicks={reducedMotion ? 0 : 10}
|
|
152
|
+
cooldownTicks={reducedMotion ? 0 : 120}
|
|
153
|
+
/>
|
|
154
|
+
{(selected || hovered) && (
|
|
155
|
+
<div className="absolute top-3 right-3 w-64 p-3 bg-surface border border-border-default rounded shadow-lg">
|
|
156
|
+
<div className="flex items-center justify-between mb-2">
|
|
157
|
+
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted">
|
|
158
|
+
{(selected || hovered)?.type}
|
|
159
|
+
</span>
|
|
160
|
+
{selected && (
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => setSelected(null)}
|
|
163
|
+
aria-label="Close details"
|
|
164
|
+
className="text-text-muted hover:text-text-primary"
|
|
165
|
+
>
|
|
166
|
+
<Icon name="X" className="w-4 h-4" />
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
<p className="text-sm text-text-primary break-words font-mono">{(selected || hovered)?.label}</p>
|
|
171
|
+
{selected && (
|
|
172
|
+
<p className="text-xs text-text-muted mt-2">{connected.length} connection(s)</p>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { ClientSession } from '../../../src/types';
|
|
3
|
+
import { Icon } from './ui/Icon';
|
|
4
|
+
import { formatDuration, formatTime, truncate } from '../../../src/lib/format';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
sessions: ClientSession[];
|
|
8
|
+
selectedSessionId: string | null;
|
|
9
|
+
activeSessionId: string | undefined;
|
|
10
|
+
connection: 'connecting' | 'connected' | 'disconnected';
|
|
11
|
+
onSelect: (id: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SessionSelector({
|
|
15
|
+
sessions,
|
|
16
|
+
selectedSessionId,
|
|
17
|
+
activeSessionId,
|
|
18
|
+
connection,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: Props) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
function onDocClick(e: MouseEvent) {
|
|
26
|
+
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
27
|
+
}
|
|
28
|
+
document.addEventListener('click', onDocClick);
|
|
29
|
+
return () => document.removeEventListener('click', onDocClick);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const current = sessions.find((s) => s.id === selectedSessionId);
|
|
33
|
+
const dotColor =
|
|
34
|
+
connection === 'connected'
|
|
35
|
+
? 'bg-success'
|
|
36
|
+
: connection === 'connecting'
|
|
37
|
+
? 'bg-warning'
|
|
38
|
+
: 'bg-error';
|
|
39
|
+
|
|
40
|
+
const label = current
|
|
41
|
+
? (current.id === activeSessionId ? '● ' : '') + (current.skill?.toUpperCase() || truncate(current.id, 12))
|
|
42
|
+
: 'No session';
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div ref={ref} className="relative">
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => setOpen((v) => !v)}
|
|
48
|
+
aria-haspopup="listbox"
|
|
49
|
+
aria-expanded={open}
|
|
50
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border-default bg-elevated text-text-secondary text-xs hover:border-accent transition-colors min-h-[36px]"
|
|
51
|
+
>
|
|
52
|
+
<span className={`w-2 h-2 rounded-full ${dotColor}`} />
|
|
53
|
+
<span>{label}</span>
|
|
54
|
+
<Icon name="CaretDown" className="w-4 h-4" />
|
|
55
|
+
</button>
|
|
56
|
+
{open && (
|
|
57
|
+
<ul
|
|
58
|
+
role="listbox"
|
|
59
|
+
className="absolute top-[calc(100%+8px)] right-0 min-w-[260px] max-h-[320px] overflow-y-auto bg-surface border border-border-default rounded z-50 shadow-lg p-1.5"
|
|
60
|
+
>
|
|
61
|
+
{sessions.map((s) => {
|
|
62
|
+
const duration = s.endedAt
|
|
63
|
+
? `ended after ${formatDuration(s.endedAt - s.startTime)}`
|
|
64
|
+
: formatDuration(Date.now() - s.startTime);
|
|
65
|
+
const isActive = s.id === selectedSessionId;
|
|
66
|
+
return (
|
|
67
|
+
<li
|
|
68
|
+
key={s.id}
|
|
69
|
+
role="option"
|
|
70
|
+
aria-selected={isActive}
|
|
71
|
+
onClick={() => {
|
|
72
|
+
onSelect(s.id);
|
|
73
|
+
setOpen(false);
|
|
74
|
+
}}
|
|
75
|
+
className={`flex items-center gap-2 px-2.5 py-2 rounded cursor-pointer text-xs border-l-2 ${
|
|
76
|
+
isActive
|
|
77
|
+
? 'bg-elevated border-accent text-text-primary'
|
|
78
|
+
: 'border-transparent text-text-secondary hover:bg-elevated'
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex flex-col min-w-0">
|
|
82
|
+
<span className="font-mono truncate">{truncate(s.id, 18)}</span>
|
|
83
|
+
<span className="text-text-muted text-[11px]">
|
|
84
|
+
{formatTime(s.startTime)} · {duration}
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
<span className="ml-auto text-text-muted uppercase">{s.source}</span>
|
|
88
|
+
</li>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</ul>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|