agent-canvas 0.2.1 → 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 +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Modal shown in shared mode when a reviewer submits feedback without a
|
|
@@ -11,85 +11,86 @@ import React, { useState, useEffect, useRef } from "react";
|
|
|
11
11
|
* the same reviewer across revisions.
|
|
12
12
|
*/
|
|
13
13
|
interface ReviewerIdentityDialogProps {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
open: boolean
|
|
15
|
+
onClose: () => void
|
|
16
|
+
onSubmit: (name: string) => void
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function ReviewerIdentityDialog({ open, onClose, onSubmit }: ReviewerIdentityDialogProps) {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const [name, setName] = useState('')
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (open) {
|
|
25
|
+
setName('')
|
|
26
|
+
setTimeout(() => inputRef.current?.focus(), 50)
|
|
27
|
+
}
|
|
28
|
+
}, [open])
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
if (!open) return null
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const trimmed = name.trim()
|
|
33
|
+
const valid = trimmed.length >= 1 && trimmed.length <= 80
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
const handleSubmit = () => {
|
|
36
|
+
if (!valid) return
|
|
37
|
+
onSubmit(trimmed)
|
|
38
|
+
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
className="bg-bg-surface border border-border-medium rounded-lg shadow-xl w-full max-w-md mx-4 p-5"
|
|
47
|
+
onClick={(e) => e.stopPropagation()}
|
|
48
|
+
>
|
|
49
|
+
<div className="flex items-center justify-between mb-3">
|
|
50
|
+
<h2 className="text-sm font-medium text-text-primary font-body">
|
|
51
|
+
Your name
|
|
52
|
+
</h2>
|
|
53
|
+
<button
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
|
|
56
|
+
>
|
|
57
|
+
<span className="text-xs">✕</span>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</p>
|
|
61
|
+
<p className="text-[13px] text-text-secondary font-body mb-3 leading-relaxed">
|
|
62
|
+
Before you leave feedback, please enter a name so the canvas author knows who reviewed it. This is stored locally in your browser.
|
|
63
|
+
</p>
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
<input
|
|
66
|
+
ref={inputRef}
|
|
67
|
+
type="text"
|
|
68
|
+
value={name}
|
|
69
|
+
onChange={(e) => setName(e.target.value)}
|
|
70
|
+
onKeyDown={(e) => {
|
|
71
|
+
if (e.key === 'Enter') handleSubmit()
|
|
72
|
+
}}
|
|
73
|
+
maxLength={80}
|
|
74
|
+
placeholder="e.g. Alice"
|
|
75
|
+
className="w-full px-3 py-2 text-[13px] font-body bg-bg-input border border-border-medium rounded text-text-primary placeholder:text-text-disabled focus:outline-none focus:border-accent-blue"
|
|
76
|
+
/>
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
79
|
+
<button
|
|
80
|
+
onClick={onClose}
|
|
81
|
+
className="px-3 py-1.5 text-[12px] font-body font-medium rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors"
|
|
82
|
+
>
|
|
83
|
+
Cancel
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
onClick={handleSubmit}
|
|
87
|
+
disabled={!valid}
|
|
88
|
+
className="px-3 py-1.5 text-[12px] font-body font-medium rounded bg-accent-blue text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
89
|
+
>
|
|
90
|
+
Submit feedback
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
95
96
|
}
|
|
@@ -1,113 +1,129 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { RevisionContext, RevisionInfo } from '@fabrika/canvas-kernel/client'
|
|
2
|
+
import { useContext, useEffect, useRef, useState } from 'react'
|
|
3
3
|
|
|
4
4
|
/** Format a revision for the trigger button: label or "Round N" */
|
|
5
5
|
function revisionButtonLabel(r: RevisionInfo) {
|
|
6
|
-
|
|
6
|
+
return r.label || `Round ${r.revision}`
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/** Format relative time from ISO string */
|
|
10
10
|
function relativeTime(iso: string): string {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
const diff = Date.now() - new Date(iso).getTime()
|
|
12
|
+
const seconds = Math.floor(diff / 1000)
|
|
13
|
+
if (seconds < 60) return 'just now'
|
|
14
|
+
const minutes = Math.floor(seconds / 60)
|
|
15
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
16
|
+
const hours = Math.floor(minutes / 60)
|
|
17
|
+
if (hours < 24) return `${hours}h ago`
|
|
18
|
+
const days = Math.floor(hours / 24)
|
|
19
|
+
return `${days}d ago`
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
interface RevisionSelectProps {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
value: number
|
|
24
|
+
onChange: (rev: number) => void
|
|
25
|
+
/** Accent color for the trigger text */
|
|
26
|
+
accent?: 'default' | 'red' | 'green' | 'amber'
|
|
27
|
+
/** Extra class on the trigger button */
|
|
28
|
+
className?: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function RevisionSelect({ value, onChange, accent =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
export function RevisionSelect({ value, onChange, accent = 'default', className }: RevisionSelectProps) {
|
|
32
|
+
const { currentRevision, revisions } = useContext(RevisionContext)
|
|
33
|
+
const [open, setOpen] = useState(false)
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!open) return
|
|
38
|
+
const handler = (e: MouseEvent) => {
|
|
39
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
40
|
+
}
|
|
41
|
+
document.addEventListener('mousedown', handler)
|
|
42
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
43
|
+
}, [open])
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const selected = revisions.find((r) => r.revision === value)
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
const accentClasses = {
|
|
48
|
+
default: 'text-text-secondary hover:text-text-primary',
|
|
49
|
+
red: 'text-accent-red',
|
|
50
|
+
green: 'text-accent-green',
|
|
51
|
+
amber: 'text-accent-amber bg-highlight-selected',
|
|
52
|
+
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
return (
|
|
55
|
+
<div className="relative" ref={ref}>
|
|
56
|
+
<button
|
|
57
|
+
onClick={() => setOpen(!open)}
|
|
58
|
+
className={`flex items-center gap-1.5 text-[12px] font-body font-medium px-2 py-1 rounded-md transition-colors hover:bg-bg-elevated ${
|
|
59
|
+
accentClasses[accent]
|
|
60
|
+
} ${className || ''}`}
|
|
61
|
+
>
|
|
62
|
+
{selected ? revisionButtonLabel(selected) : `Round ${value}`}
|
|
63
|
+
<svg
|
|
64
|
+
width="10"
|
|
65
|
+
height="10"
|
|
66
|
+
viewBox="0 0 10 10"
|
|
67
|
+
fill="none"
|
|
68
|
+
className={`text-text-tertiary transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
|
69
|
+
>
|
|
70
|
+
<path d="M2.5 4l2.5 2.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
71
|
+
</svg>
|
|
72
|
+
</button>
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
74
|
+
{open && (
|
|
75
|
+
<div className="absolute top-full left-0 mt-1 w-64 bg-bg-elevated border border-border-hover rounded-lg shadow-lg z-50 py-1 max-h-80 overflow-y-auto">
|
|
76
|
+
{[...revisions].reverse().map((r) => (
|
|
77
|
+
<button
|
|
78
|
+
key={r.revision}
|
|
79
|
+
onClick={() => {
|
|
80
|
+
onChange(r.revision)
|
|
81
|
+
setOpen(false)
|
|
82
|
+
}}
|
|
83
|
+
className={`w-full flex items-center gap-2 px-3 py-2 text-[12px] font-body transition-colors ${
|
|
84
|
+
r.revision === value
|
|
85
|
+
? 'bg-bg-surface text-text-primary'
|
|
86
|
+
: 'text-text-secondary hover:bg-bg-surface hover:text-text-primary'
|
|
87
|
+
}`}
|
|
88
|
+
>
|
|
89
|
+
<span
|
|
90
|
+
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
91
|
+
r.revision === currentRevision ? 'bg-accent-green' : r.hasFeedback ? 'bg-accent-amber' : 'bg-border-hover'
|
|
92
|
+
}`}
|
|
93
|
+
/>
|
|
94
|
+
<span className="flex-1 text-left truncate">
|
|
95
|
+
{r.label
|
|
96
|
+
? (
|
|
97
|
+
<>
|
|
98
|
+
{r.label} <span className="text-text-tertiary">#{r.revision}</span>
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
: <>Round {r.revision}</>}
|
|
102
|
+
</span>
|
|
103
|
+
<span className="text-[10px] text-text-tertiary flex-shrink-0 flex items-center gap-1.5">
|
|
104
|
+
{(() => {
|
|
105
|
+
const stats = r.canvasFiles?.reduce((acc, cf) => {
|
|
106
|
+
if (cf.diffStats) {
|
|
107
|
+
acc.added += cf.diffStats.added
|
|
108
|
+
acc.removed += cf.diffStats.removed
|
|
109
|
+
}
|
|
110
|
+
return acc
|
|
111
|
+
}, { added: 0, removed: 0 })
|
|
112
|
+
if (stats && (stats.added > 0 || stats.removed > 0)) {
|
|
113
|
+
return (
|
|
114
|
+
<span className="font-mono">
|
|
115
|
+
<span className="text-accent-green">+{stats.added}</span> <span className="text-accent-red">-{stats.removed}</span>
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
})()}
|
|
121
|
+
{r.revision === currentRevision ? 'current' : r.hasFeedback ? 'sent' : relativeTime(r.createdAt)}
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
113
129
|
}
|
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { FS_AVAILABLE } from
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { FS_AVAILABLE } from './clientApi'
|
|
3
3
|
|
|
4
4
|
interface SessionInfo {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
id: string
|
|
6
|
+
projectRoot?: string
|
|
7
|
+
currentRevision: number
|
|
8
|
+
updatedAt: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
function sessionDisplayName(id: string, projectRoot?: string): string {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
if (projectRoot) {
|
|
13
|
+
const parts = projectRoot.replace(/\/+$/, '').split('/')
|
|
14
|
+
return parts[parts.length - 1] || id
|
|
15
|
+
}
|
|
16
|
+
return id.length > 30 ? id.slice(0, 30) + '...' : id
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
interface SessionSwitcherProps {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
currentSessionId: string
|
|
21
|
+
projectRoot?: string
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function SessionSwitcher({ currentSessionId, projectRoot }: SessionSwitcherProps) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([])
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
// /api/sessions only exists on the local daemon. In shared mode we skip
|
|
31
|
+
// the switcher entirely (it's hidden by the parent), but guard the poll
|
|
32
|
+
// just in case this component is mounted — nothing to switch to.
|
|
33
|
+
if (!FS_AVAILABLE) return
|
|
34
|
+
const fetchSessions = async () => {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch('/api/sessions')
|
|
37
|
+
const data = await res.json() as SessionInfo[]
|
|
38
|
+
setSessions((prev) => {
|
|
39
|
+
const next = JSON.stringify(data)
|
|
40
|
+
return JSON.stringify(prev) === next ? prev : data
|
|
41
|
+
})
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
fetchSessions()
|
|
45
|
+
const interval = setInterval(fetchSessions, 5000)
|
|
46
|
+
return () => clearInterval(interval)
|
|
47
|
+
}, [])
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return
|
|
51
|
+
const handler = (e: MouseEvent) => {
|
|
52
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
53
|
+
}
|
|
54
|
+
document.addEventListener('mousedown', handler)
|
|
55
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
56
|
+
}, [open])
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const otherSessions = sessions.filter((s) => s.id !== currentSessionId)
|
|
59
|
+
const displayName = sessionDisplayName(currentSessionId, projectRoot)
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
61
|
+
return (
|
|
62
|
+
<div className="relative" ref={ref}>
|
|
63
|
+
<div className="flex items-center gap-1.5 font-body text-body font-medium">
|
|
64
|
+
<a
|
|
65
|
+
href={`/s/${currentSessionId}`}
|
|
66
|
+
className="text-text-primary hover:text-text-secondary transition-colors"
|
|
67
|
+
>
|
|
68
|
+
{displayName}
|
|
69
|
+
</a>
|
|
70
|
+
{otherSessions.length > 0 && (
|
|
71
|
+
<button
|
|
72
|
+
onClick={() => setOpen(!open)}
|
|
73
|
+
className="flex items-center justify-center text-text-tertiary hover:text-text-secondary transition-colors"
|
|
74
|
+
>
|
|
75
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}>
|
|
76
|
+
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
77
|
+
</svg>
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
{open && otherSessions.length > 0 && (
|
|
83
|
+
<div className="absolute top-full left-0 mt-2 w-64 bg-bg-elevated border border-border-hover rounded-lg shadow-lg z-50 py-1 overflow-hidden">
|
|
84
|
+
{otherSessions.map((s) => (
|
|
85
|
+
<a
|
|
86
|
+
key={s.id}
|
|
87
|
+
href={`/s/${s.id}`}
|
|
88
|
+
className="flex items-center gap-2.5 px-4 py-2.5 text-body font-body text-text-secondary hover:bg-bg-surface hover:text-text-primary transition-colors"
|
|
89
|
+
>
|
|
90
|
+
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.currentRevision > 0 ? 'bg-accent-green' : 'bg-border-hover'}`} />
|
|
91
|
+
<span className="truncate">{sessionDisplayName(s.id, s.projectRoot)}</span>
|
|
92
|
+
</a>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
98
|
}
|