feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews

This commit is contained in:
hh
2026-08-29 18:30:33 +02:00
parent a989d2909d
commit 9efff6e814
58 changed files with 2858 additions and 499 deletions
+489 -46
View File
@@ -1,54 +1,452 @@
{ {
"schemaVersion": 2, "schemaVersion": 2,
"generatedAt": "2026-08-28T00:00:00Z", "generatedAt": "2026-08-29T00:00:00Z",
"title": "Design System: Beaver admin", "title": "Design System: Beaver admin",
"extensions": { "extensions": {
"colorMeta": { "colorMeta": {
"primary": { "role": "primary", "displayName": "Mulberry", "canonical": "oklch(0.47 0.13 340)", "dark": "#7c3871", "tonalRamp": ["oklch(0.15 0.13 340)", "oklch(0.26 0.13 340)", "oklch(0.37 0.13 340)", "oklch(0.47 0.13 340)", "oklch(0.58 0.13 340)", "oklch(0.70 0.13 340)", "oklch(0.83 0.13 340)", "oklch(0.95 0.13 340)"] }, "primary": {
"signal": { "role": "primary", "displayName": "Orchid Signal", "canonical": "oklch(0.48 0.21 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.21 342)", "oklch(0.26 0.21 342)", "oklch(0.37 0.21 342)", "oklch(0.48 0.21 342)", "oklch(0.60 0.21 342)", "oklch(0.72 0.21 342)", "oklch(0.84 0.21 342)", "oklch(0.95 0.21 342)"] }, "role": "primary",
"ring": { "role": "primary", "displayName": "Focus Ring", "canonical": "oklch(0.55 0.2 342)", "dark": "#ff82f3", "tonalRamp": ["oklch(0.15 0.2 342)", "oklch(0.26 0.2 342)", "oklch(0.38 0.2 342)", "oklch(0.55 0.2 342)", "oklch(0.64 0.2 342)", "oklch(0.74 0.2 342)", "oklch(0.85 0.2 342)", "oklch(0.95 0.2 342)"] }, "displayName": "Mulberry",
"status-reply": { "role": "secondary", "displayName": "Branch Blue", "canonical": "oklch(0.55 0.14 235)", "dark": "oklch(0.72 0.13 235)", "tonalRamp": ["oklch(0.15 0.14 235)", "oklch(0.26 0.14 235)", "oklch(0.40 0.14 235)", "oklch(0.55 0.14 235)", "oklch(0.65 0.14 235)", "oklch(0.75 0.14 235)", "oklch(0.85 0.14 235)", "oklch(0.95 0.14 235)"] }, "canonical": "oklch(0.47 0.13 340)",
"status-meeting": { "role": "secondary", "displayName": "Deep Violet", "canonical": "oklch(0.5 0.2 300)", "dark": "oklch(0.72 0.16 300)", "tonalRamp": ["oklch(0.15 0.2 300)", "oklch(0.26 0.2 300)", "oklch(0.38 0.2 300)", "oklch(0.50 0.2 300)", "oklch(0.62 0.2 300)", "oklch(0.74 0.2 300)", "oklch(0.85 0.2 300)", "oklch(0.95 0.2 300)"] }, "dark": "#7c3871",
"status-work": { "role": "secondary", "displayName": "Job Teal", "canonical": "oklch(0.55 0.13 195)", "dark": "oklch(0.75 0.12 195)", "tonalRamp": ["oklch(0.15 0.13 195)", "oklch(0.26 0.13 195)", "oklch(0.40 0.13 195)", "oklch(0.55 0.13 195)", "oklch(0.65 0.13 195)", "oklch(0.75 0.13 195)", "oklch(0.85 0.13 195)", "oklch(0.95 0.13 195)"] }, "tonalRamp": [
"status-skip": { "role": "secondary", "displayName": "Fork Grey", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] }, "oklch(0.15 0.13 340)",
"status-done": { "role": "tertiary", "displayName": "OK Green", "canonical": "oklch(0.55 0.14 155)", "dark": "oklch(0.74 0.14 155)", "tonalRamp": ["oklch(0.15 0.14 155)", "oklch(0.26 0.14 155)", "oklch(0.40 0.14 155)", "oklch(0.55 0.14 155)", "oklch(0.65 0.14 155)", "oklch(0.75 0.14 155)", "oklch(0.85 0.14 155)", "oklch(0.95 0.14 155)"] }, "oklch(0.26 0.13 340)",
"warn": { "role": "tertiary", "displayName": "Warn Amber", "canonical": "oklch(0.55 0.13 75)", "dark": "oklch(0.8 0.13 75)", "tonalRamp": ["oklch(0.15 0.13 75)", "oklch(0.26 0.13 75)", "oklch(0.40 0.13 75)", "oklch(0.55 0.13 75)", "oklch(0.65 0.13 75)", "oklch(0.75 0.13 75)", "oklch(0.85 0.13 75)", "oklch(0.95 0.13 75)"] }, "oklch(0.37 0.13 340)",
"note": { "role": "tertiary", "displayName": "Inject Amber", "canonical": "oklch(0.52 0.13 78)", "dark": "oklch(0.84 0.14 88)", "tonalRamp": ["oklch(0.15 0.13 78)", "oklch(0.26 0.13 78)", "oklch(0.40 0.13 78)", "oklch(0.52 0.13 78)", "oklch(0.64 0.13 78)", "oklch(0.75 0.13 78)", "oklch(0.85 0.13 78)", "oklch(0.95 0.13 78)"] }, "oklch(0.47 0.13 340)",
"destructive": { "role": "tertiary", "displayName": "Error Red", "canonical": "oklch(0.58 0.22 25)", "dark": "oklch(0.62 0.2 25)", "tonalRamp": ["oklch(0.15 0.22 25)", "oklch(0.26 0.22 25)", "oklch(0.40 0.22 25)", "oklch(0.58 0.22 25)", "oklch(0.66 0.22 25)", "oklch(0.75 0.22 25)", "oklch(0.85 0.22 25)", "oklch(0.95 0.22 25)"] }, "oklch(0.58 0.13 340)",
"background": { "role": "neutral", "displayName": "Blush Paper", "canonical": "oklch(0.99 0.004 340)", "dark": "#22111e", "tonalRamp": ["oklch(0.15 0.004 340)", "oklch(0.26 0.004 340)", "oklch(0.40 0.004 340)", "oklch(0.55 0.004 340)", "oklch(0.70 0.004 340)", "oklch(0.85 0.004 340)", "oklch(0.95 0.004 340)", "oklch(0.99 0.004 340)"] }, "oklch(0.70 0.13 340)",
"foreground": { "role": "neutral", "displayName": "Plum Ink", "canonical": "oklch(0.24 0.03 340)", "dark": "#f5f5f5", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.24 0.03 340)", "oklch(0.38 0.03 340)", "oklch(0.50 0.03 340)", "oklch(0.62 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.87 0.03 340)", "oklch(0.95 0.03 340)"] }, "oklch(0.83 0.13 340)",
"sidebar": { "role": "neutral", "displayName": "Sidebar Layer", "canonical": "oklch(0.975 0.01 340)", "dark": "#2c1b29", "tonalRamp": ["oklch(0.15 0.01 340)", "oklch(0.26 0.01 340)", "oklch(0.40 0.01 340)", "oklch(0.55 0.01 340)", "oklch(0.70 0.01 340)", "oklch(0.85 0.01 340)", "oklch(0.94 0.01 340)", "oklch(0.975 0.01 340)"] }, "oklch(0.95 0.13 340)"
"sidebar-accent": { "role": "neutral", "displayName": "Sidebar Accent", "canonical": "oklch(0.94 0.016 340)", "dark": "#341d2f", "tonalRamp": ["oklch(0.15 0.016 340)", "oklch(0.26 0.016 340)", "oklch(0.40 0.016 340)", "oklch(0.55 0.016 340)", "oklch(0.70 0.016 340)", "oklch(0.85 0.016 340)", "oklch(0.94 0.016 340)", "oklch(0.98 0.016 340)"] }, ]
"muted": { "role": "neutral", "displayName": "Muted", "canonical": "oklch(0.96 0.012 340)", "dark": "#40283a", "tonalRamp": ["oklch(0.15 0.012 340)", "oklch(0.26 0.012 340)", "oklch(0.40 0.012 340)", "oklch(0.55 0.012 340)", "oklch(0.70 0.012 340)", "oklch(0.85 0.012 340)", "oklch(0.96 0.012 340)", "oklch(0.99 0.012 340)"] }, },
"muted-foreground": { "role": "neutral", "displayName": "Muted Ink", "canonical": "oklch(0.5 0.04 340)", "dark": "#a989a3", "tonalRamp": ["oklch(0.15 0.04 340)", "oklch(0.26 0.04 340)", "oklch(0.38 0.04 340)", "oklch(0.50 0.04 340)", "oklch(0.62 0.04 340)", "oklch(0.74 0.04 340)", "oklch(0.85 0.04 340)", "oklch(0.95 0.04 340)"] }, "signal": {
"icon": { "role": "neutral", "displayName": "Icon", "canonical": "oklch(0.55 0.03 340)", "dark": "#877384", "tonalRamp": ["oklch(0.15 0.03 340)", "oklch(0.26 0.03 340)", "oklch(0.40 0.03 340)", "oklch(0.55 0.03 340)", "oklch(0.65 0.03 340)", "oklch(0.75 0.03 340)", "oklch(0.85 0.03 340)", "oklch(0.95 0.03 340)"] }, "role": "primary",
"border": { "role": "neutral", "displayName": "Hairline", "canonical": "oklch(0.9 0.02 340)", "dark": "oklch(0.92 0.04 340 / 10%)", "tonalRamp": ["oklch(0.15 0.02 340)", "oklch(0.26 0.02 340)", "oklch(0.40 0.02 340)", "oklch(0.55 0.02 340)", "oklch(0.70 0.02 340)", "oklch(0.80 0.02 340)", "oklch(0.90 0.02 340)", "oklch(0.97 0.02 340)"] } "displayName": "Orchid Signal",
"canonical": "oklch(0.48 0.21 342)",
"dark": "#ff82f3",
"tonalRamp": [
"oklch(0.15 0.21 342)",
"oklch(0.26 0.21 342)",
"oklch(0.37 0.21 342)",
"oklch(0.48 0.21 342)",
"oklch(0.60 0.21 342)",
"oklch(0.72 0.21 342)",
"oklch(0.84 0.21 342)",
"oklch(0.95 0.21 342)"
]
},
"ring": {
"role": "primary",
"displayName": "Focus Ring",
"canonical": "oklch(0.55 0.2 342)",
"dark": "#ff82f3",
"tonalRamp": [
"oklch(0.15 0.2 342)",
"oklch(0.26 0.2 342)",
"oklch(0.38 0.2 342)",
"oklch(0.55 0.2 342)",
"oklch(0.64 0.2 342)",
"oklch(0.74 0.2 342)",
"oklch(0.85 0.2 342)",
"oklch(0.95 0.2 342)"
]
},
"status-reply": {
"role": "secondary",
"displayName": "Branch Blue",
"canonical": "oklch(0.55 0.14 235)",
"dark": "oklch(0.72 0.13 235)",
"tonalRamp": [
"oklch(0.15 0.14 235)",
"oklch(0.26 0.14 235)",
"oklch(0.40 0.14 235)",
"oklch(0.55 0.14 235)",
"oklch(0.65 0.14 235)",
"oklch(0.75 0.14 235)",
"oklch(0.85 0.14 235)",
"oklch(0.95 0.14 235)"
]
},
"status-meeting": {
"role": "secondary",
"displayName": "Deep Violet",
"canonical": "oklch(0.5 0.2 300)",
"dark": "oklch(0.72 0.16 300)",
"tonalRamp": [
"oklch(0.15 0.2 300)",
"oklch(0.26 0.2 300)",
"oklch(0.38 0.2 300)",
"oklch(0.50 0.2 300)",
"oklch(0.62 0.2 300)",
"oklch(0.74 0.2 300)",
"oklch(0.85 0.2 300)",
"oklch(0.95 0.2 300)"
]
},
"status-work": {
"role": "secondary",
"displayName": "Job Teal",
"canonical": "oklch(0.55 0.13 195)",
"dark": "oklch(0.75 0.12 195)",
"tonalRamp": [
"oklch(0.15 0.13 195)",
"oklch(0.26 0.13 195)",
"oklch(0.40 0.13 195)",
"oklch(0.55 0.13 195)",
"oklch(0.65 0.13 195)",
"oklch(0.75 0.13 195)",
"oklch(0.85 0.13 195)",
"oklch(0.95 0.13 195)"
]
},
"status-skip": {
"role": "secondary",
"displayName": "Fork Grey",
"canonical": "oklch(0.5 0.04 340)",
"dark": "#a989a3",
"tonalRamp": [
"oklch(0.15 0.04 340)",
"oklch(0.26 0.04 340)",
"oklch(0.38 0.04 340)",
"oklch(0.50 0.04 340)",
"oklch(0.62 0.04 340)",
"oklch(0.74 0.04 340)",
"oklch(0.85 0.04 340)",
"oklch(0.95 0.04 340)"
]
},
"status-done": {
"role": "tertiary",
"displayName": "OK Green",
"canonical": "oklch(0.55 0.14 155)",
"dark": "oklch(0.74 0.14 155)",
"tonalRamp": [
"oklch(0.15 0.14 155)",
"oklch(0.26 0.14 155)",
"oklch(0.40 0.14 155)",
"oklch(0.55 0.14 155)",
"oklch(0.65 0.14 155)",
"oklch(0.75 0.14 155)",
"oklch(0.85 0.14 155)",
"oklch(0.95 0.14 155)"
]
},
"warn": {
"role": "tertiary",
"displayName": "Warn Amber",
"canonical": "oklch(0.55 0.13 75)",
"dark": "oklch(0.8 0.13 75)",
"tonalRamp": [
"oklch(0.15 0.13 75)",
"oklch(0.26 0.13 75)",
"oklch(0.40 0.13 75)",
"oklch(0.55 0.13 75)",
"oklch(0.65 0.13 75)",
"oklch(0.75 0.13 75)",
"oklch(0.85 0.13 75)",
"oklch(0.95 0.13 75)"
]
},
"note": {
"role": "tertiary",
"displayName": "Inject Amber",
"canonical": "oklch(0.52 0.13 78)",
"dark": "oklch(0.84 0.14 88)",
"tonalRamp": [
"oklch(0.15 0.13 78)",
"oklch(0.26 0.13 78)",
"oklch(0.40 0.13 78)",
"oklch(0.52 0.13 78)",
"oklch(0.64 0.13 78)",
"oklch(0.75 0.13 78)",
"oklch(0.85 0.13 78)",
"oklch(0.95 0.13 78)"
]
},
"destructive": {
"role": "tertiary",
"displayName": "Error Red",
"canonical": "oklch(0.58 0.22 25)",
"dark": "oklch(0.62 0.2 25)",
"tonalRamp": [
"oklch(0.15 0.22 25)",
"oklch(0.26 0.22 25)",
"oklch(0.40 0.22 25)",
"oklch(0.58 0.22 25)",
"oklch(0.66 0.22 25)",
"oklch(0.75 0.22 25)",
"oklch(0.85 0.22 25)",
"oklch(0.95 0.22 25)"
]
},
"background": {
"role": "neutral",
"displayName": "Blush Paper",
"canonical": "oklch(0.99 0.004 340)",
"dark": "#22111e",
"tonalRamp": [
"oklch(0.15 0.004 340)",
"oklch(0.26 0.004 340)",
"oklch(0.40 0.004 340)",
"oklch(0.55 0.004 340)",
"oklch(0.70 0.004 340)",
"oklch(0.85 0.004 340)",
"oklch(0.95 0.004 340)",
"oklch(0.99 0.004 340)"
]
},
"foreground": {
"role": "neutral",
"displayName": "Plum Ink",
"canonical": "oklch(0.24 0.03 340)",
"dark": "#f5f5f5",
"tonalRamp": [
"oklch(0.15 0.03 340)",
"oklch(0.24 0.03 340)",
"oklch(0.38 0.03 340)",
"oklch(0.50 0.03 340)",
"oklch(0.62 0.03 340)",
"oklch(0.75 0.03 340)",
"oklch(0.87 0.03 340)",
"oklch(0.95 0.03 340)"
]
},
"sidebar": {
"role": "neutral",
"displayName": "Sidebar Layer",
"canonical": "oklch(0.975 0.01 340)",
"dark": "#2c1b29",
"tonalRamp": [
"oklch(0.15 0.01 340)",
"oklch(0.26 0.01 340)",
"oklch(0.40 0.01 340)",
"oklch(0.55 0.01 340)",
"oklch(0.70 0.01 340)",
"oklch(0.85 0.01 340)",
"oklch(0.94 0.01 340)",
"oklch(0.975 0.01 340)"
]
},
"sidebar-accent": {
"role": "neutral",
"displayName": "Sidebar Accent",
"canonical": "oklch(0.94 0.016 340)",
"dark": "#341d2f",
"tonalRamp": [
"oklch(0.15 0.016 340)",
"oklch(0.26 0.016 340)",
"oklch(0.40 0.016 340)",
"oklch(0.55 0.016 340)",
"oklch(0.70 0.016 340)",
"oklch(0.85 0.016 340)",
"oklch(0.94 0.016 340)",
"oklch(0.98 0.016 340)"
]
},
"muted": {
"role": "neutral",
"displayName": "Muted",
"canonical": "oklch(0.96 0.012 340)",
"dark": "#40283a",
"tonalRamp": [
"oklch(0.15 0.012 340)",
"oklch(0.26 0.012 340)",
"oklch(0.40 0.012 340)",
"oklch(0.55 0.012 340)",
"oklch(0.70 0.012 340)",
"oklch(0.85 0.012 340)",
"oklch(0.96 0.012 340)",
"oklch(0.99 0.012 340)"
]
},
"muted-foreground": {
"role": "neutral",
"displayName": "Muted Ink",
"canonical": "oklch(0.5 0.04 340)",
"dark": "#a989a3",
"tonalRamp": [
"oklch(0.15 0.04 340)",
"oklch(0.26 0.04 340)",
"oklch(0.38 0.04 340)",
"oklch(0.50 0.04 340)",
"oklch(0.62 0.04 340)",
"oklch(0.74 0.04 340)",
"oklch(0.85 0.04 340)",
"oklch(0.95 0.04 340)"
]
},
"icon": {
"role": "neutral",
"displayName": "Icon",
"canonical": "oklch(0.55 0.03 340)",
"dark": "#877384",
"tonalRamp": [
"oklch(0.15 0.03 340)",
"oklch(0.26 0.03 340)",
"oklch(0.40 0.03 340)",
"oklch(0.55 0.03 340)",
"oklch(0.65 0.03 340)",
"oklch(0.75 0.03 340)",
"oklch(0.85 0.03 340)",
"oklch(0.95 0.03 340)"
]
},
"border": {
"role": "neutral",
"displayName": "Hairline",
"canonical": "oklch(0.9 0.02 340)",
"dark": "oklch(0.92 0.04 340 / 10%)",
"tonalRamp": [
"oklch(0.15 0.02 340)",
"oklch(0.26 0.02 340)",
"oklch(0.40 0.02 340)",
"oklch(0.55 0.02 340)",
"oklch(0.70 0.02 340)",
"oklch(0.80 0.02 340)",
"oklch(0.90 0.02 340)",
"oklch(0.97 0.02 340)"
]
}
}, },
"typographyMeta": { "typographyMeta": {
"headline": { "displayName": "Headline", "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking." }, "headline": {
"title": { "displayName": "Title", "purpose": "Thread title, stat values (tabular), memory file path." }, "displayName": "Headline",
"body": { "displayName": "Body", "purpose": "Rows, cells, bubbles, dialogs; primary row text at 500." }, "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking."
"label": { "displayName": "Label", "purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines." }, },
"mono": { "displayName": "Mono", "purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token." } "title": {
"displayName": "Title",
"purpose": "Thread title, stat values (tabular), memory file path."
},
"body": {
"displayName": "Body",
"purpose": "Rows, cells, bubbles, dialogs; primary row text at 500."
},
"label": {
"displayName": "Label",
"purpose": "Uppercase section headings; same size without uppercase for table headers, badges, pills, meta lines."
},
"mono": {
"displayName": "Mono",
"purpose": "Raw JSON, tool input/result blocks, event tape, ids, the plaintext token."
}
}, },
"shadows": [ "shadows": [
{ "name": "login-lift", "value": "0 8px 24px -12px rgb(0 0 0 / 0.25)", "purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow." }, {
{ "name": "popover", "value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)", "purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only." } "name": "login-lift",
"value": "0 8px 24px -12px rgb(0 0 0 / 0.25)",
"purpose": "The sign-in card only (routes/login/+page.svelte). The single application-layer shadow."
},
{
"name": "popover",
"value": "shadow-xl plus ring-1 ring-foreground/5 (10% dark)",
"purpose": "Luma dialogs, sheets, menus, selects. Overlay surfaces only."
}
], ],
"motion": [ "motion": [
{ "name": "pulse-dot", "value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite", "purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots." }, {
{ "name": "rise", "value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both", "purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed." }, "name": "pulse-dot",
{ "name": "row-hover", "value": "background-color 150ms ease-out", "purpose": "Ink wash at 4% on interactive rows." }, "value": "pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite",
{ "name": "chevron", "value": "transform 150ms", "purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree." }, "purpose": "Signal-colored halo (0 to 5px, 55% to 0% alpha) on live and running dots."
{ "name": "bar-fill", "value": "width 300ms", "purpose": "Utilization bar fill in limit-bar.svelte." }, },
{ "name": "dialog", "value": "fade-in and zoom-in-95 over 100ms", "purpose": "Luma dialog open and close." }, {
{ "name": "reduced-motion", "value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion", "purpose": "Global kill switch in layout.css." } "name": "rise",
"value": "rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both",
"purpose": "Opacity 0 to 1 and translateY 3px to 0 when a turn card lands in the activity feed."
},
{
"name": "row-hover",
"value": "background-color 150ms ease-out",
"purpose": "Ink wash at 4% on interactive rows."
},
{
"name": "chevron",
"value": "transform 150ms",
"purpose": "90 degree rotation of expand chevrons in tool nodes, raw entries, memory tree."
},
{
"name": "bar-fill",
"value": "width 300ms",
"purpose": "Utilization bar fill in limit-bar.svelte."
},
{
"name": "dialog",
"value": "fade-in and zoom-in-95 over 100ms",
"purpose": "Luma dialog open and close."
},
{
"name": "reduced-motion",
"value": "transition-duration and animation-duration 0.01ms under prefers-reduced-motion",
"purpose": "Global kill switch in layout.css."
}
], ],
"breakpoints": [ "breakpoints": [
{ "name": "sm", "value": "640px", "purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return." }, {
{ "name": "md", "value": "768px", "purpose": "Memory tree and file split side by side." }, "name": "sm",
{ "name": "lg", "value": "1024px", "purpose": "Thread meta rail (18rem) and two-column dashboard sections." } "value": "640px",
] "purpose": "Sidebar replaces bottom nav; header padding 24px; hidden row columns return."
},
{
"name": "md",
"value": "768px",
"purpose": "Memory tree and file split side by side."
},
{
"name": "lg",
"value": "1024px",
"purpose": "Thread meta rail (18rem) and two-column dashboard sections."
},
{
"name": "panel-rail",
"value": "768px",
"purpose": "Panel container width, not viewport: below it the switcher bar names the thread, from it the 18rem conversation rail shows."
}
],
"obsidian": {
"root": ".beaver-root",
"source": "beaver-plugin-obsidian/src/tailwind.css",
"aliases": {
"background": "--background-primary",
"card": "--background-primary-alt",
"popover": "--background-secondary",
"sidebar": "--background-secondary",
"foreground": "--text-normal",
"muted-foreground": "--text-muted",
"icon": "--text-muted",
"status-skip": "--text-faint",
"border": "--background-modifier-border",
"input": "--background-modifier-border",
"muted": "--background-modifier-hover",
"secondary": "--background-modifier-hover",
"accent": "--background-modifier-hover",
"sidebar-accent": "--background-modifier-hover",
"primary": "--color-accent",
"signal": "--color-accent",
"ring": "--color-accent",
"status-new": "--color-accent",
"primary-foreground": "--text-on-accent",
"link": "--link-color, --text-accent",
"destructive": "--text-error",
"font-sans": "--font-interface",
"font-mono": "--font-monospace"
},
"radii": {
"button, select-trigger": "--button-radius",
"input, tabs-trigger, menu items": "--input-radius",
"tabs-list, skeleton, dialog, popover, menus": "--radius-m (8px)",
"rows and notes": "--radius 0.45rem"
},
"overlays": {
"border": "1px var(--border)",
"shadow": "var(--shadow-s)",
"menuBackground": "var(--popover), opaque, no backdrop",
"portal": ".beaver-root.beaver-portal on body at --layer-menu (65)"
},
"tabs": {
"list": "color-mix(in oklab, var(--text-normal) 6%, transparent)",
"active": "color-mix(in oklab, var(--text-normal) 14%, transparent)"
}
}
}, },
"components": [ "components": [
{ {
@@ -153,12 +551,51 @@
"Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays" "Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays"
], ],
"rules": [ "rules": [
{ "name": "The Pink Ration Rule", "body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.", "section": "colors" }, {
{ "name": "The Twelve Percent Rule", "body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.", "section": "colors" }, "name": "The Pink Ration Rule",
{ "name": "The Two Layers Rule", "body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.", "section": "colors" }, "body": "signal marks live state (pulsing dots, running rows, busy sessions, the running count) and selection (active mobile nav, master kind). It never decorates a heading, a button, or a static surface. Hover is a 4% ink wash, not pink.",
{ "name": "The Tabular Rule", "body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.", "section": "typography" }, "section": "colors"
{ "name": "The One Heading Rule", "body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.", "section": "typography" }, },
{ "name": "The Flat Ledger Rule", "body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.", "section": "elevation" } {
"name": "The Twelve Percent Rule",
"body": "Every colored chip is its hue at 12% alpha behind full-strength text of the same hue (kind-badge.svelte, sidebar running count). Solid colored chips do not exist.",
"section": "colors"
},
{
"name": "The Two Layers Rule",
"body": "Navigation and rails sit on sidebar (or sidebar/50); content sits on background. There is no third surface color; depth beyond that is a hairline.",
"section": "colors"
},
{
"name": "The Tabular Rule",
"body": "Every number - durations, token counts, money, percentages, timestamps, ids, byte sizes - carries the tabular utility (font-variant-numeric: tabular-nums, layout.css). Numeric table columns are right-aligned.",
"section": "typography"
},
{
"name": "The One Heading Rule",
"body": "A page has one headline (the header) and any number of uppercase 12px section labels; there is no intermediate heading size. Section labels are h2 elements, not decorative kickers.",
"section": "typography"
},
{
"name": "The Flat Ledger Rule",
"body": "Nothing inside the main column casts a shadow. Overlays (dialog, sheet, menu) and the login card are the only lifted surfaces.",
"section": "elevation"
},
{
"name": "The Host Accent Rule",
"body": "Inside Obsidian signal and primary are one variable. Live dots, running rows, the running count, the selected row, the primary button and the focus ring all take the user's accent; the Pink Ration Rule becomes an accent ration with the same two meanings, live and selected.",
"section": "obsidian"
},
{
"name": "The Borrowed Corner Rule",
"body": "No control chooses its own radius. Rows and notes use --radius; luma primitives are re-rounded to Obsidian's --button-radius, --input-radius and --radius-m so the panel sits beside the app's own settings and menus without a seam.",
"section": "obsidian"
},
{
"name": "The Alias Only Rule",
"body": "Panel components use token aliases (bg-primary/5, text-link, border-b) and the panel.css utilities. A literal colour, font or radius in ui/src/lib/panel/ breaks the Obsidian mapping.",
"section": "obsidian"
}
], ],
"dos": [ "dos": [
"Do build lists as ledger-grid rows with border-b and a per-row column template; use a native table when there are more than four numeric columns.", "Do build lists as ledger-grid rows with border-b and a per-row column template; use a native table when there are more than four numeric columns.",
@@ -170,7 +607,10 @@
"Do put secondary surfaces (rails, trees) on bg-sidebar/50 with a hairline, not on a card.", "Do put secondary surfaces (rails, trees) on bg-sidebar/50 with a hairline, not on a card.",
"Do show every error as error-note.svelte with a retry and every empty list as empty-state.svelte with a one-line hint.", "Do show every error as error-note.svelte with a retry and every empty list as empty-state.svelte with a one-line hint.",
"Do respect prefers-reduced-motion; new motion must be a CSS transition or one of animate-pulse-dot and animate-rise.", "Do respect prefers-reduced-motion; new motion must be a CSS transition or one of animate-pulse-dot and animate-rise.",
"Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions." "Do keep the luma primitives as shipped; compose them with size=\"sm\" and variant=\"outline\" or \"ghost\" for in-page actions.",
"Do write panel components (ui/src/lib/panel/) against token aliases and panel.css utilities only, and check them in both the admin and the plugin.",
"Do decide panel layout from the panel's own width (@container, bind:clientWidth); 768px is where the rail replaces the switcher.",
"Do add conversation actions to actions.svelte.ts so the header menu and the row menu stay one list."
], ],
"donts": [ "donts": [
"Don't put a card inside a card, or wrap ledger rows in a bordered container.", "Don't put a card inside a card, or wrap ledger rows in a bordered container.",
@@ -179,7 +619,10 @@
"Don't introduce a heading size between the page headline (1rem) and the section label (0.75rem uppercase).", "Don't introduce a heading size between the page headline (1rem) and the section label (0.75rem uppercase).",
"Don't render solid colored chips; chips are hue/12 behind hue text.", "Don't render solid colored chips; chips are hue/12 behind hue text.",
"Don't use hue-chip, hairline, touch-shown or scrollbar-none from layout.css as if they were system rules - they are defined and currently unused by the admin.", "Don't use hue-chip, hairline, touch-shown or scrollbar-none from layout.css as if they were system rules - they are defined and currently unused by the admin.",
"Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment)." "Don't apply this palette inside Obsidian; the panel takes Obsidian's variables (PRODUCT.md brand commitment).",
"Don't expect pills inside Obsidian; every radius there is the host's (--button-radius, --input-radius, --radius-m).",
"Don't mark an active tab with bg-background in panel code; the plugin overrides it to ink at 14%.",
"Don't let a menu or dialog render inside the sidedock; overlays mount in .beaver-root.beaver-portal at --layer-menu."
] ]
} }
} }
@@ -0,0 +1,13 @@
---
version: 1
slug: "beaver-gateway-ui-src-lib-panel"
primary_target: "beaver-gateway/ui/src/lib/panel"
related_targets: ["beaver-gateway/ui/src/routes/panel","beaver-plugin-obsidian/src/ui"]
---
Scope: the Beaver panel as an Obsidian view (sidedock column, tab, mobile) and its browser twin at /admin/panel. Mode: Operate.
Audience and job: the operator (Kit) watching and steering the agent next to their notes; the task is to see what runs, answer, branch, send a branch back to Telegram, close a deep chat.
Constraints: shared components in ui/src/lib/panel, bundled into the plugin by esbuild ($lib alias, one Svelte runtime, svelte-sonner shimmed to Notice); container queries, never viewport breakpoints, because the same tree lives in a 300px column and a 1400px tab; Obsidian's variables only through .beaver-root (beaver-calendar is the reference); nothing pink inside Obsidian.
Direction: the agent's terminal, thread always on screen; below 48rem the switcher popover in the header, above it the grouped rail (master, branches, deep chats, jobs) on the left; one action list (actions.svelte.ts) behind the header menu and the row context menu; markdown through Obsidian's renderer with [[links]] opening notes.
Memorable moment: the panel follows the note you open and the running turn's tool tree grows in place.
Unresolved: a "new conversation" affordance in the panel; keyboard navigation beyond ↑/↓ and /; the pinned one-line summary when a branch returns to Telegram (§8.2) is not produced by the gateway yet.
+7 -1
View File
@@ -497,14 +497,20 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
conv = await conv_of(public_id) conv = await conv_of(public_id)
data = await body_of(request) data = await body_of(request)
frontend = text_of(data, "frontend") frontend = text_of(data, "frontend")
external_id = text_of(data, "external_id") external_id = data.get("external_id")
try: try:
if isinstance(external_id, str) and external_id.strip():
await conversations.bind( await conversations.bind(
conv, conv,
frontend=frontend, frontend=frontend,
external_id=external_id, external_id=external_id,
visible=bool(data.get("visible", True)), visible=bool(data.get("visible", True)),
) )
elif await conversations.frontend(frontend).materialize(conv) is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"frontend {frontend!r} cannot open a window for {conv.kind!r}",
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return await conversations.describe(conv) return await conversations.describe(conv)
@@ -35,6 +35,10 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
ALLOWED_UPDATES = ("message", "callback_query") ALLOWED_UPDATES = ("message", "callback_query")
def _unset(_value: object) -> None:
"""Unset aiogram ``Default`` sentinels (link previews) are stored as null."""
class Inbox: class Inbox:
def __init__( def __init__(
self, self,
@@ -85,7 +89,9 @@ class Inbox:
async def _store(self, update: Update) -> None: async def _store(self, update: Update) -> None:
row = TelegramUpdate( row = TelegramUpdate(
update_id=update.update_id, update_id=update.update_id,
payload=update.model_dump(mode="json", by_alias=True, exclude_none=True), payload=update.model_dump(
mode="json", by_alias=True, exclude_none=True, fallback=_unset
),
) )
async with self._db.session() as session: async with self._db.session() as session:
session.add(row) session.add(row)
+42
View File
@@ -448,3 +448,45 @@ async def test_close_distills_a_deep_chat(world: World) -> None:
f"/conversations/{branch.external_id}/close", headers=HEADERS f"/conversations/{branch.external_id}/close", headers=HEADERS
) )
assert plain.status_code == 200 and plain.json()["status"] == "closed" assert plain.status_code == 200 and plain.json()["status"] == "closed"
async def test_bind_without_a_window_materializes_one(world: World) -> None:
api = Api(world)
chat = await world.conversations.create(kind="deep", agent="d", origin="test")
world.markdown.materialized.clear()
res = await api.http.post(
f"/conversations/{chat.external_id}/bind",
json={"frontend": "markdown"},
headers=HEADERS,
)
assert res.status_code == 200, res.text
assert world.markdown.materialized == [chat.external_id]
assert res.json()["bindings"] == [
{
"frontend": "markdown",
"external_id": f"markdown:{chat.external_id}",
"visible": True,
}
]
hidden = await api.http.post(
f"/conversations/{chat.external_id}/bind",
json={
"frontend": "markdown",
"external_id": f"markdown:{chat.external_id}",
"visible": False,
},
headers=HEADERS,
)
assert hidden.json()["bindings"][0]["visible"] is False
no_window = await api.http.post(
f"/conversations/{chat.external_id}/bind",
json={"frontend": "api"},
headers=HEADERS,
)
assert no_window.status_code == 400
unknown = await api.http.post(
f"/conversations/{chat.external_id}/bind",
json={"frontend": "nope"},
headers=HEADERS,
)
assert unknown.status_code == 400
+21
View File
@@ -593,3 +593,24 @@ def test_render_helpers() -> None:
assert status_label("Read", {"file_path": "/x"}) == "читаю vault" assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts" assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la" assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"
async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None:
stack.bot.push(
{
"message": {
"message_id": 77,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"text": "see https://x.y",
"link_preview_options": {"url": "https://x.y"},
}
}
)
await stack.until(lambda: stack.sent_with("see https://x.y"), what="reply")
async with stack.world.db.session() as session:
rows = list((await session.exec(select(TelegramUpdate))).all())
preview = rows[0].payload["message"]["link_preview_options"]
assert preview["url"] == "https://x.y"
assert preview.get("is_disabled") is None
+2 -2
View File
@@ -12,7 +12,7 @@
"@biomejs/biome": "2.5.9", "@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@internationalized/date": "^3.12.3", "@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.35.0", "@lucide/svelte": "^1.37.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -88,7 +88,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@1.35.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-1I5WeiEFc21HOLv/GR11vuN3MqN/k/mhCEMW7nEtwSAGOcZ0C5ZgI1Y2Rvs/egfKTUiOTw+R9Ys9BJ+GwAKSWw=="], "@lucide/svelte": ["@lucide/svelte@1.37.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-AxaiSxeuErlt4fNmmN28oaL5R7yVoQLsfSeiAONxCEK+l0/SrLAjRktQ0DkTQpomv1oRzOA3hohWY0ZHTZCMmw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+1 -1
View File
@@ -21,7 +21,7 @@
"@biomejs/biome": "2.5.9", "@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@internationalized/date": "^3.12.3", "@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.35.0", "@lucide/svelte": "^1.37.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
+3 -2
View File
@@ -245,14 +245,15 @@ export class ApiClient {
return this.post(`/api/conversations/${id}/close`); return this.post(`/api/conversations/${id}/close`);
} }
// Without ``externalId`` the frontend opens a fresh window (a new topic).
bind( bind(
id: string, id: string,
frontend: string, frontend: string,
externalId: string, externalId: string | null,
visible: boolean visible: boolean
): Promise<ConversationInfo> { ): Promise<ConversationInfo> {
return this.post(`/api/conversations/${id}/bind`, { return this.post(`/api/conversations/${id}/bind`, {
external_id: externalId, external_id: externalId ?? undefined,
frontend, frontend,
visible, visible,
}); });
@@ -0,0 +1,41 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
inset?: boolean;
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="context-menu-checkbox-item"
data-inset={inset}
class={cn(
"gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium focus:bg-accent focus:text-accent-foreground data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>
@@ -0,0 +1,30 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
import ContextMenuPortal from "./context-menu-portal.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
portalProps,
class: className,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<
ComponentProps<typeof ContextMenuPortal>
>;
} = $props();
</script>
<ContextMenuPortal {...portalProps}>
<ContextMenuPrimitive.Content
bind:ref
data-slot="context-menu-content"
class={cn(
"min-w-48 rounded-3xl p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 overflow-x-hidden overflow-y-auto outline-none animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
{...restProps}
/>
</ContextMenuPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.GroupHeading
bind:ref
data-slot="context-menu-group-heading"
data-inset={inset}
class={cn("px-2 py-1.5 text-sm font-medium text-foreground data-inset:ps-8", className)}
{...restProps}
/>
@@ -0,0 +1,12 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps =
$props();
</script>
<ContextMenuPrimitive.Group
bind:ref
data-slot="context-menu-group"
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
variant = "default",
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: "default" | "destructive";
} = $props();
</script>
<ContextMenuPrimitive.Item
bind:ref
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium focus:bg-accent focus:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="context-menu-label"
data-inset={inset}
class={cn("px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5 data-inset:pl-8", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
</script>
<ContextMenuPrimitive.Portal {...restProps} />
@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(""),
...restProps
}: ContextMenuPrimitive.RadioGroupProps = $props();
</script>
<ContextMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="context-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
data-slot="context-menu-radio-item"
data-inset={inset}
class={cn(
"gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium focus:bg-accent focus:text-accent-foreground data-inset:pl-9.5 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute right-2 pointer-events-none">
{#if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>
@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
data-slot="context-menu-separator"
class={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="context-menu-shortcut"
class={cn("ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground", className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
data-slot="context-menu-sub-content"
class={cn("min-w-32 rounded-3xl p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
data-slot="context-menu-sub-trigger"
data-inset={inset}
class={cn(
"rounded-2xl px-3 py-2 text-sm font-medium focus:bg-accent focus:text-accent-foreground data-inset:pl-9.5 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-inset:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
@@ -0,0 +1,8 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps =
$props();
</script>
<ContextMenuPrimitive.Sub bind:open {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.TriggerProps = $props();
</script>
<ContextMenuPrimitive.Trigger
bind:ref
data-slot="context-menu-trigger"
class={cn("cn-context-menu-trigger select-none", className)}
{...restProps}
/>
@@ -0,0 +1,10 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
let {
open = $bindable(false),
...restProps
}: ContextMenuPrimitive.RootProps = $props();
</script>
<ContextMenuPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,52 @@
import CheckboxItem from "./context-menu-checkbox-item.svelte";
import Content from "./context-menu-content.svelte";
import GroupHeading from "./context-menu-group-heading.svelte";
import Group from "./context-menu-group.svelte";
import Item from "./context-menu-item.svelte";
import Label from "./context-menu-label.svelte";
import Portal from "./context-menu-portal.svelte";
import RadioGroup from "./context-menu-radio-group.svelte";
import RadioItem from "./context-menu-radio-item.svelte";
import Separator from "./context-menu-separator.svelte";
import Shortcut from "./context-menu-shortcut.svelte";
import SubContent from "./context-menu-sub-content.svelte";
import SubTrigger from "./context-menu-sub-trigger.svelte";
import Sub from "./context-menu-sub.svelte";
import Trigger from "./context-menu-trigger.svelte";
import Root from "./context-menu.svelte";
export {
Root,
Sub,
Portal,
Item,
GroupHeading,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Portal as ContextMenuPortal,
Item as ContextMenuItem,
GroupHeading as ContextMenuGroupHeading,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem,
Label as ContextMenuLabel,
};
+28
View File
@@ -0,0 +1,28 @@
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Description from "./popover-description.svelte";
import Header from "./popover-header.svelte";
import Portal from "./popover-portal.svelte";
import Title from "./popover-title.svelte";
import Trigger from "./popover-trigger.svelte";
import Root from "./popover.svelte";
export {
Root,
Content,
Description,
Header,
Title,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Description as PopoverDescription,
Header as PopoverHeader,
Title as PopoverTitle,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,8 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps =
$props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import PopoverPortal from "./popover-portal.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"flex flex-col gap-4 rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn("text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn("flex flex-col gap-1 text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-title"
class={cn("font-heading text-base font-medium", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,8 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps =
$props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+16 -94
View File
@@ -1,124 +1,54 @@
import { LiveStream } from "./api/live.svelte"; import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
import type { import { ConversationIndex } from "./panel/index.svelte";
BusEvent,
ConversationSummary,
LimitsResponse,
LimitWindow,
} from "./api/types";
import { session } from "./session.svelte"; import { session } from "./session.svelte";
const TAPE_SIZE = 120; const TAPE_SIZE = 120;
const RELOAD_DEBOUNCE_MS = 1500;
const QUIET = new Set(["stream", "hello"]); const QUIET = new Set(["stream", "hello"]);
// Gateway-wide live state: the conversation index kept fresh by // Gateway-wide live state for the admin: the conversation index, the last
// ``/api/events``, the last non-chatty events as a tape, and the quota // non-chatty events as a tape, and the quota windows updated the moment
// windows updated the moment the SDK reports them. // the SDK reports them.
class Gateway { class Gateway extends ConversationIndex {
conversations = $state<ConversationSummary[]>([]);
tape = $state<BusEvent[]>([]); tape = $state<BusEvent[]>([]);
limits = $state<LimitsResponse | null>(null); limits = $state<LimitsResponse | null>(null);
loaded = $state(false);
readonly live = new LiveStream(() => session.client, "/api/events", {
onEvent: (event) => this.apply(event),
prepare: () => this.load(),
});
get running(): ConversationSummary[] { constructor() {
return this.conversations.filter((row) => row.running_turn); super(() => session.client);
} }
get open(): ConversationSummary[] { override async load(): Promise<void> {
return this.conversations.filter((row) => row.status === "open"); const client = this.client();
}
byId(id: string): ConversationSummary | undefined {
return this.conversations.find((row) => row.id === id);
}
async load(): Promise<void> {
const { client } = session;
if (!client) { if (!client) {
return; return;
} }
const [list, limits] = await Promise.all([ const [, limits] = await Promise.all([
client.conversations({ limit: 500 }), super.load(),
client.limits().catch(() => null), client.limits().catch(() => null),
]); ]);
this.conversations = list.conversations;
if (limits) { if (limits) {
this.limits = limits; this.limits = limits;
} }
this.loaded = true;
} }
async refreshLimits(): Promise<void> { async refreshLimits(): Promise<void> {
const { client } = session; const client = this.client();
if (client) { if (client) {
this.limits = await client.limits(); this.limits = await client.limits();
} }
} }
private upsert(row: ConversationSummary): void { override apply(event: BusEvent): void {
const index = this.conversations.findIndex((c) => c.id === row.id);
if (index >= 0) {
this.conversations[index] = { ...this.conversations[index], ...row };
} else {
this.conversations.unshift(row);
}
}
apply(event: BusEvent): void {
if (!QUIET.has(event.type)) { if (!QUIET.has(event.type)) {
this.tape.unshift(event); this.tape.unshift(event);
if (this.tape.length > TAPE_SIZE) { if (this.tape.length > TAPE_SIZE) {
this.tape.length = TAPE_SIZE; this.tape.length = TAPE_SIZE;
} }
} }
switch (event.type) { if (event.type === "rate_limit") {
case "conversation.created":
case "conversation.updated": {
if (typeof event.id === "string") {
this.upsert(event as unknown as ConversationSummary);
}
return;
}
case "turn.start":
case "turn.end": {
const row = event.conversation_id
? this.byId(event.conversation_id)
: undefined;
if (row) {
row.running_turn =
event.type === "turn.start" ? (event.turn_id ?? null) : null;
row.last_activity_at = event.ts;
}
return;
}
case "rate_limit": {
this.applyLimit(event); this.applyLimit(event);
return; return;
} }
case "message.queued": super.apply(event);
case "inject.queued":
case "reply": {
this.reloadSoon();
return;
}
default:
}
}
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
private reloadSoon(): void {
if (this.reloadTimer) {
return;
}
this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;
this.load().catch(() => undefined);
}, RELOAD_DEBOUNCE_MS);
} }
private applyLimit(event: BusEvent): void { private applyLimit(event: BusEvent): void {
@@ -140,14 +70,6 @@ class Gateway {
this.refreshLimits().catch(() => undefined); this.refreshLimits().catch(() => undefined);
} }
} }
start(): void {
this.live.start();
}
stop(): void {
this.live.stop();
}
} }
export const gateway = new Gateway(); export const gateway = new Gateway();
+224
View File
@@ -0,0 +1,224 @@
import ArchiveIcon from "@lucide/svelte/icons/archive";
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
import BookCheckIcon from "@lucide/svelte/icons/book-check";
import BrainIcon from "@lucide/svelte/icons/brain";
import CircleXIcon from "@lucide/svelte/icons/circle-x";
import CopyIcon from "@lucide/svelte/icons/copy";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
import PencilIcon from "@lucide/svelte/icons/pencil";
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
import SendIcon from "@lucide/svelte/icons/send";
import type { Component } from "svelte";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationInfo } from "$lib/api/types";
import type { PanelHost } from "./host";
export interface MenuAction {
// Present: rendered as a checkbox item with this state.
checked?: boolean;
danger?: boolean;
disabled?: boolean;
// A separator sits above this action.
gap?: boolean;
icon?: Component<{ class?: string }>;
label: string;
run: () => void | Promise<void>;
}
export interface ActionScope {
client: ApiClient;
// The conversation is the one on screen, so "Open" is pointless.
current: boolean;
host: PanelHost;
onBranch: () => void;
onChanged: () => void;
onOpen: (id: string) => void;
onRename: () => void;
}
const TELEGRAM = "telegram";
const MARKDOWN = "markdown";
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function attempt(
action: () => Promise<unknown>,
done: string | null
): Promise<void> {
try {
await action();
if (done) {
toast.success(done);
}
} catch (error) {
toast.error(describe(error));
}
}
function copyId(id: string): void {
navigator.clipboard
.writeText(id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
// The one list of things you can do to a conversation, in the order the
// header menu and the row's context menu both show them.
export function conversationActions(
info: ConversationInfo,
scope: ActionScope
): MenuAction[] {
const { client, host } = scope;
const open = info.status === "open";
const telegram = info.bindings.find((b) => b.frontend === TELEGRAM);
const note = info.bindings.find((b) => b.frontend === MARKDOWN && b.visible);
const remembers = info.flags.memory !== false;
const changed = () => scope.onChanged();
const actions: MenuAction[] = [];
if (!scope.current) {
actions.push({
icon: ArrowRightIcon,
label: "Open",
run: () => scope.onOpen(info.id),
});
}
if (note && host.openNote) {
actions.push({
icon: FileTextIcon,
label: "Open note",
run: () => host.openNote?.(note.external_id),
});
}
actions.push({
disabled: !open,
gap: actions.length > 0,
icon: GitBranchIcon,
label: "Branch…",
run: scope.onBranch,
});
actions.push({
checked: remembers,
gap: true,
icon: BrainIcon,
label: "Remember on close",
run: () =>
attempt(
async () => {
await client.setFlags(info.id, { memory: !remembers });
changed();
},
remembers
? "Memory off: closing only merges"
: "Memory on: closing writes a digest"
),
});
if (info.kind === "branch") {
if (telegram?.visible) {
actions.push({
icon: EyeOffIcon,
label: "Hide from Telegram",
run: () =>
attempt(async () => {
await client.bind(info.id, TELEGRAM, telegram.external_id, false);
changed();
}, "Hidden from Telegram"),
});
} else {
actions.push({
icon: SendIcon,
label: "Return to Telegram",
run: () =>
attempt(async () => {
await client.bind(
info.id,
TELEGRAM,
telegram?.external_id ?? null,
true
);
changed();
}, "Back in Telegram"),
});
}
}
actions.push({
gap: true,
icon: PencilIcon,
label: "Rename…",
run: scope.onRename,
});
actions.push({
icon: CopyIcon,
label: "Copy id",
run: () => copyId(info.id),
});
if (info.parent && open) {
actions.push({
gap: true,
icon: GitMergeIcon,
label: "Merge into parent",
run: () =>
attempt(async () => {
await client.merge(info.id);
changed();
}, "Merged into the parent"),
});
}
if (info.kind === "deep" && open) {
actions.push({
gap: !(info.parent && open),
icon: BookCheckIcon,
label: "Close with digest",
run: () =>
attempt(async () => {
const result = await client.close(info.id);
changed();
if (result.error) {
toast.warning(`Digest: ${result.error}`);
} else if (result.digest) {
toast.message(`Digest: ${result.digest}`);
}
}, "Chat closed"),
});
}
if (open) {
actions.push({
gap: !(info.parent || info.kind === "deep"),
icon: CircleXIcon,
label: "Close",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "closed" });
changed();
}, "Closed"),
});
actions.push({
danger: true,
icon: ArchiveIcon,
label: "Archive",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "archived" });
changed();
}, "Archived"),
});
} else {
actions.push({
gap: true,
icon: RotateCcwIcon,
label: "Reopen",
run: () =>
attempt(async () => {
await client.update(info.id, { status: "open" });
changed();
}, "Reopened"),
});
}
return actions;
}
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { shortId } from "$lib/format";
let {
client,
parent,
open = $bindable(false),
onOpened,
}: {
client: ApiClient;
parent: Pick<ConversationSummary, "id" | "title" | "kind">;
open?: boolean;
onOpened: (id: string) => void;
} = $props();
const SEEDS = [
{ hint: "empty context", label: "clean", value: "clean" },
{ hint: "the morning handout", label: "morning", value: "morning" },
{ hint: "a copy of this history", label: "copy", value: "copy" },
{ hint: "your text as the seed", label: "brief", value: "brief" },
];
let seed = $state("clean");
let title = $state("");
let text = $state("");
let busy = $state(false);
const current = $derived(SEEDS.find((s) => s.value === seed) ?? SEEDS[0]);
const parentName = $derived(
parent.title || `${parent.kind} ${shortId(parent.id)}`
);
async function branch() {
busy = true;
try {
const child = await client.branch(parent.id, {
seed,
text: text || undefined,
title: title || undefined,
});
open = false;
title = "";
text = "";
toast.success("Branch opened");
onOpened(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off {parentName}</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{current.label}
<span class="text-muted-foreground">- {current.hint}</span>
</Select.Trigger>
<Select.Content>
{#each SEEDS as option (option.value)}
<Select.Item label={option.label} value={option.value}>
<span class="flex flex-col">
<span>{option.label}</span>
<span class="text-muted-foreground text-xs">
{option.hint}
</span>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input id="branch-title" placeholder="optional" bind:value={title} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !text.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+1 -1
View File
@@ -124,7 +124,7 @@
</script> </script>
<div <div
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 py-3 sm:px-6" class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3 @md:px-6"
onscroll={() => { onscroll={() => {
pinned = nearBottom(); pinned = nearBottom();
}} }}
+2 -2
View File
@@ -111,7 +111,7 @@
{/each} {/each}
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
<span class="hidden text-muted-foreground text-xs sm:inline" <span class="hidden text-muted-foreground text-xs @md:inline"
>{current.hint}</span >{current.hint}</span
> >
<Button <Button
@@ -122,7 +122,7 @@
> >
<SendHorizontalIcon class="size-4" /> <SendHorizontalIcon class="size-4" />
Send Send
<kbd class="hidden text-[10px] opacity-70 sm:inline">⌘↩</kbd> <kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
</Button> </Button>
</div> </div>
</form> </form>
+52 -239
View File
@@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import GitBranchIcon from "@lucide/svelte/icons/git-branch"; import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte"; import type { LiveState } from "$lib/api/live.svelte";
import type { ConversationInfo } from "$lib/api/types"; import type { ConversationInfo } from "$lib/api/types";
@@ -10,114 +7,67 @@
import LiveDot from "$lib/components/live-dot.svelte"; import LiveDot from "$lib/components/live-dot.svelte";
import StatusPill from "$lib/components/status-pill.svelte"; import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { clip, fmtRelative, shortId } from "$lib/format"; import { clip, fmtRelative, shortId } from "$lib/format";
import BranchDialog from "./branch-dialog.svelte";
import ConversationMenu from "./conversation-menu.svelte";
let { let {
client, client,
info, info,
liveState, liveState,
liveDetail = null, liveDetail = null,
href, href = null,
onChanged, onChanged,
onOpen, onOpen,
showTitle = true,
}: { }: {
client: ApiClient; client: ApiClient;
info: ConversationInfo; info: ConversationInfo;
liveState: LiveState; liveState: LiveState;
liveDetail?: string | null; liveDetail?: string | null;
href: (id: string) => string; // Where another conversation lives as a link; without it, parents open in place.
href?: ((id: string) => string) | null;
onChanged: () => void; onChanged: () => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
showTitle?: boolean;
} = $props(); } = $props();
let branchOpen = $state(false); let branchOpen = $state(false);
let renameOpen = $state(false);
let busy = $state(false);
let seed = $state("clean");
let branchTitle = $state("");
let branchText = $state("");
let newTitle = $state("");
const WINDOW_MAX = 56; const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible)); const windows = $derived(info.bindings.filter((b) => b.visible));
const queued = $derived( const queued = $derived(
info.queue.filter((item) => item.status === "queued").length info.queue.filter((item) => item.status === "queued").length
); );
const SEEDS = [
{ label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" },
{ label: "brief - your text as the seed", value: "brief" },
{ label: "morning - the handout", value: "morning" },
];
async function run(action: () => Promise<unknown>, done: string) {
busy = true;
try {
await action();
toast.success(done);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function branch() {
busy = true;
try {
const child = await client.branch(info.id, {
seed,
text: branchText || undefined,
title: branchTitle || undefined,
});
branchOpen = false;
toast.success("Branch opened");
onOpen(child.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
async function distill() {
const result = await client.close(info.id);
if (result.error) {
toast.warning(`Digest: ${result.error}`);
} else if (result.digest) {
toast.message(`Digest: ${result.digest}`);
}
}
function copyId() {
navigator.clipboard
.writeText(info.id)
.then(() => toast.success("Id copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script> </script>
<header class="flex flex-col gap-1.5 border-b px-4 py-2.5 sm:px-6"> <header class="flex flex-col gap-1.5 border-b px-3 py-2.5 @md:px-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if showTitle}
<KindBadge kind={info.kind} /> <KindBadge kind={info.kind} />
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight"> <h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
{info.title || `${info.kind} ${shortId(info.id)}`} {info.title || `${info.kind} ${shortId(info.id)}`}
</h1> </h1>
{/if}
<StatusPill status={info.running_turn ? "running" : info.status} /> <StatusPill status={info.running_turn ? "running" : info.status} />
{#if info.pending_question} {#if info.pending_question}
<span class="font-medium text-link text-xs">question pending</span> <span class="font-medium text-link text-xs">question pending</span>
{/if} {/if}
<div class="ml-auto flex items-center gap-1"> <div class="ml-auto flex items-center gap-1">
<LiveDot detail={liveDetail} state={liveState} /> <LiveDot
class="hidden @md:inline-flex"
detail={liveDetail}
state={liveState}
/>
<LiveDot
class="@md:hidden"
detail={liveDetail}
label={false}
state={liveState}
/>
<Button <Button
disabled={busy || info.status !== "open"} aria-label="Branch off this conversation"
disabled={info.status !== "open"}
onclick={() => { onclick={() => {
branchOpen = true; branchOpen = true;
}} }}
@@ -125,88 +75,44 @@
variant="outline" variant="outline"
> >
<GitBranchIcon class="size-4" /> <GitBranchIcon class="size-4" />
Branch <span class="hidden @md:inline">Branch</span>
</Button> </Button>
<DropdownMenu.Root> <ConversationMenu
<DropdownMenu.Trigger> {client}
{#snippet child({ props })} current
<Button id={info.id}
{...props} {info}
aria-label="More actions" mode="button"
size="icon-sm" {onChanged}
variant="ghost" {onOpen}
> />
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={copyId}>
<CopyIcon class="size-4" />
Copy id
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
newTitle = info.title ?? "";
renameOpen = true;
}}
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if info.parent && info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.merge(info.id), "Merged into the parent")}
>
Merge into parent
</DropdownMenu.Item>
{/if}
{#if info.kind === "deep" && info.status === "open"}
<DropdownMenu.Item onclick={() => run(distill, "Chat closed")}>
Close with digest
</DropdownMenu.Item>
{/if}
{#if info.status === "open"}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "closed" }), "Closed")}
>
Close
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
run(
() => client.update(info.id, { status: "archived" }),
"Archived"
)}
>
Archive
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onclick={() =>
run(() => client.update(info.id, { status: "open" }), "Reopened")}
>
Reopen
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div> </div>
</div> </div>
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
<div <div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs" class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
> >
<span>{info.agent}</span> <span class="hidden @md:inline">{info.agent}</span>
<span>via {info.origin}</span> <span class="hidden @md:inline">via {info.origin}</span>
{#if info.parent} {#if info.parent}
{#if href}
<a class="text-link hover:underline" href={href(info.parent)}> <a class="text-link hover:underline" href={href(info.parent)}>
parent {shortId(info.parent)} parent {shortId(info.parent)}
</a> </a>
{:else}
<button
class="text-link hover:underline"
onclick={() => info.parent && onOpen(info.parent)}
type="button"
>
parent {shortId(info.parent)}
</button>
{/if} {/if}
<span class="tabular" title={info.id}>{shortId(info.id)}</span> {/if}
<span class="tabular"> <span class="tabular hidden @md:inline" title={info.id}>
{shortId(info.id)}
</span>
<span class="tabular hidden @md:inline">
{info.live ? "session live" : "no live session"} {info.live ? "session live" : "no live session"}
{info.busy ? " · busy" : ""} {info.busy ? " · busy" : ""}
</span> </span>
@@ -225,7 +131,7 @@
</div> </div>
{#if windows.length > 0} {#if windows.length > 0}
<div <div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs" class="hidden flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs @md:flex"
> >
{#each windows as window (window.frontend + window.external_id)} {#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}"> <span class="truncate" title="{window.frontend}: {window.external_id}">
@@ -237,97 +143,4 @@
{/if} {/if}
</header> </header>
<Dialog.Root bind:open={branchOpen}> <BranchDialog {client} onOpened={onOpen} parent={info} bind:open={branchOpen} />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Branch off this conversation</Dialog.Title>
<Dialog.Description>
A branch gets its own window and session and ends with a merge back into
this thread.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label>Seed</Label>
<Select.Root
onValueChange={(value) => {
seed = value;
}}
type="single"
value={seed}
>
<Select.Trigger class="w-full">
{SEEDS.find((s) => s.value === seed)?.label}
</Select.Trigger>
<Select.Content>
{#each SEEDS as option (option.value)}
<Select.Item label={option.label} value={option.value} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-title">Title</Label>
<Input
id="branch-title"
placeholder="optional"
bind:value={branchTitle}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="branch-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="branch-text"
placeholder={seed === "brief" ? "required for a brief seed" : "optional"}
bind:value={branchText}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
branchOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy || (seed === "brief" && !branchText.trim())}
onclick={branch}
>
Open branch
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={renameOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<Input placeholder="Title" bind:value={newTitle} />
<Dialog.Footer>
<Button
onclick={() => {
renameOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() =>
run(async () => {
await client.update(info.id, { title: newTitle });
renameOpen = false;
}, "Renamed")}
>
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import MoreHorizontalIcon from "@lucide/svelte/icons/more-horizontal";
import type { Snippet } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ConversationInfo } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as ContextMenu from "$lib/components/ui/context-menu";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Skeleton } from "$lib/components/ui/skeleton";
import { cn } from "$lib/utils";
import { type ActionScope, conversationActions } from "./actions.svelte";
import BranchDialog from "./branch-dialog.svelte";
import { usePanelHost } from "./host";
import MenuItems, { type MenuKit } from "./menu-items.svelte";
import RenameDialog from "./rename-dialog.svelte";
let {
client,
id,
info = null,
mode,
current = false,
onOpen,
onChanged,
class: className = "",
children,
}: {
client: ApiClient;
id: string;
// Known already (the thread header); otherwise fetched when the menu opens.
info?: ConversationInfo | null;
mode: "context" | "button";
current?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
class?: string;
children?: Snippet;
} = $props();
const host = usePanelHost();
let open = $state(false);
let fetched = $state<ConversationInfo | null>(null);
let failure = $state<string | null>(null);
let branchOpen = $state(false);
let renameOpen = $state(false);
const loaded = $derived(info ?? fetched);
$effect(() => {
if (!(open && info === null)) {
return;
}
failure = null;
client
.conversation(id)
.then((result) => {
fetched = result;
})
.catch((error: unknown) => {
failure = error instanceof Error ? error.message : String(error);
});
});
const scope = $derived<ActionScope>({
client,
current,
host,
onBranch: () => {
branchOpen = true;
},
onChanged,
onOpen,
onRename: () => {
renameOpen = true;
},
});
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
</script>
{#snippet body(kit: MenuKit)}
{#if loaded}
<MenuItems {actions} {kit} />
{:else if failure}
<p class="px-3 py-2 text-destructive text-xs">{failure}</p>
{:else}
<div class="flex flex-col gap-2 px-3 py-2">
<Skeleton class="h-3.5 w-28" />
<Skeleton class="h-3.5 w-36" />
<Skeleton class="h-3.5 w-24" />
</div>
{/if}
{/snippet}
{#if mode === "context"}
<ContextMenu.Root bind:open>
<ContextMenu.Trigger class={className}>
{@render children?.()}
</ContextMenu.Trigger>
<ContextMenu.Content class="min-w-52">
{@render body(ContextMenu)}
</ContextMenu.Content>
</ContextMenu.Root>
{:else}
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="Conversation actions"
class={cn("shrink-0", className)}
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-52">
{@render body(DropdownMenu)}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{#if loaded}
<BranchDialog
{client}
onOpened={onOpen}
parent={loaded}
bind:open={branchOpen}
/>
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
{/if}
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary, Kind } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Skeleton } from "$lib/components/ui/skeleton";
import ConversationRow from "./conversation-row.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
autofocus = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
autofocus?: boolean;
} = $props();
const GROUPS: { kind: Kind; label: string }[] = [
{ kind: "master", label: "Master" },
{ kind: "branch", label: "Branches" },
{ kind: "deep", label: "Deep chats" },
{ kind: "job", label: "Jobs" },
{ kind: "fork", label: "Forks" },
];
let search = $state("");
let withClosed = $state(false);
let list = $state<HTMLDivElement | null>(null);
let searchInput = $state<HTMLInputElement | null>(null);
function activityOf(row: ConversationSummary): string {
return row.last_activity_at ?? row.created_at ?? "";
}
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return activityOf(b).localeCompare(activityOf(a));
}
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return index.conversations
.filter(
(row) => withClosed || row.status === "open" || row.id === selected
)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
)
.sort(byActivity);
});
const groups = $derived(
GROUPS.map((group) => ({
...group,
rows: rows.filter((row) => row.kind === group.kind),
})).filter((group) => group.rows.length > 0)
);
function refresh() {
index.load().catch(() => undefined);
}
// ↑/↓ walk the rows, / jumps to the search box, Enter is the button's own.
function onKeydown(event: KeyboardEvent) {
if (event.key === "/" && event.target !== searchInput) {
event.preventDefault();
searchInput?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const buttons = [
...(list?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []),
];
if (buttons.length === 0) {
return;
}
event.preventDefault();
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
const step = event.key === "ArrowDown" ? 1 : -1;
const next = at < 0 ? 0 : (at + step + buttons.length) % buttons.length;
buttons[next].focus();
}
$effect(() => {
if (autofocus) {
searchInput?.focus();
}
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
aria-label="Conversations"
class="flex h-full min-h-0 flex-col"
onkeydown={onKeydown}
role="listbox"
tabindex="-1"
>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Input
aria-label="Search conversations"
class="h-8 min-w-0 flex-1 text-xs"
placeholder="search"
bind:ref={searchInput}
bind:value={search}
/>
<Button
aria-pressed={withClosed}
class="text-xs"
onclick={() => {
withClosed = !withClosed;
}}
size="xs"
title={withClosed
? "Hide closed conversations"
: "Show closed conversations"}
variant={withClosed ? "secondary" : "ghost"}
>
{#if withClosed}
<CheckIcon class="size-3" />
{/if}
closed
</Button>
{#if showFollow}
<Button
aria-label="Follow the active note"
aria-pressed={follow}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
{/if}
</div>
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={list}>
{#if index.live.state === "failed"}
<div class="p-3">
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
</div>
{:else if !index.loaded}
<div class="flex flex-col gap-2 p-3">
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-3">
<EmptyState
hint={search
? "Nothing matches. Try fewer letters, or include closed ones."
: "Nothing is open. Say something to the master in Telegram, or send a note from Obsidian."}
title="No conversations"
/>
</div>
{:else}
{#each groups as group (group.kind)}
<section class="flex flex-col">
<h2
class="sticky top-0 z-10 bg-background/95 px-3 pt-3 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide backdrop-blur-sm"
>
{group.label}
<span class="tabular ml-1 normal-case tracking-normal">
{group.rows.length}
</span>
</h2>
<ul class="flex flex-col">
{#each group.rows as row (row.id)}
<li class="border-b last:border-b-0">
<ConversationRow
{client}
onChanged={refresh}
{onOpen}
{row}
selected={selected === row.id}
/>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import KindBadge from "$lib/components/kind-badge.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { clip, fmtRelative, shortId } from "$lib/format";
import { cn } from "$lib/utils";
import ConversationMenu from "./conversation-menu.svelte";
import { usePanelHost } from "./host";
let {
client,
row,
selected = false,
onOpen,
onChanged,
}: {
client: ApiClient;
row: ConversationSummary;
selected?: boolean;
onOpen: (id: string) => void;
onChanged: () => void;
} = $props();
const host = usePanelHost();
const TITLE_MAX = 60;
const PREVIEW_MAX = 90;
const title = $derived(
row.title
? clip(row.title, TITLE_MAX)
: `${row.kind === "master" ? "Master" : row.kind} · ${shortId(row.id)}`
);
const when = $derived(row.last_activity_at ?? row.created_at ?? null);
</script>
<ConversationMenu
class={cn("group/row block", selected && "bg-sidebar-accent")}
{client}
current={selected}
id={row.id}
mode="context"
{onChanged}
{onOpen}
>
<div class="relative">
<button
aria-current={selected ? "true" : undefined}
class="row-hover flex w-full flex-col items-stretch justify-start gap-1 px-3 py-2 text-left text-sm"
data-row={row.id}
onclick={() => onOpen(row.id)}
type="button"
>
<span class="flex w-full items-center gap-2">
<KindBadge kind={row.kind} />
<span class="min-w-0 flex-1 truncate font-medium">{title}</span>
<span class="tabular shrink-0 text-muted-foreground text-xs">
{fmtRelative(when)}
</span>
</span>
<span class="flex w-full items-center gap-2 pr-7 text-xs">
<StatusPill status={row.running_turn ? "running" : row.status} />
<span class="truncate text-muted-foreground">{row.agent}</span>
{#if row.pending_question}
<span class="shrink-0 font-medium text-link">question</span>
{/if}
</span>
{#if row.last_item}
<span
class="block w-full min-w-0 truncate text-muted-foreground text-xs"
>
<span class="text-foreground/70">{row.last_item.origin}:</span>
{clip(row.last_item.text, PREVIEW_MAX)}
</span>
{/if}
</button>
<ConversationMenu
class={cn(
"absolute right-1.5 bottom-1 size-7 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/row:opacity-100 aria-expanded:opacity-100",
host.touch && "opacity-100",
row.last_item && "bottom-6"
)}
{client}
current={selected}
id={row.id}
mode="button"
{onChanged}
{onOpen}
/>
</div>
</ConversationMenu>
+13 -7
View File
@@ -16,13 +16,16 @@
let { let {
client, client,
id, id,
href, href = null,
onOpen, onOpen,
showTitle = true,
}: { }: {
client: ApiClient; client: ApiClient;
id: string; id: string;
href: (id: string) => string; href?: ((id: string) => string) | null;
onOpen: (id: string) => void; onOpen: (id: string) => void;
// Off when a switcher above the thread already names it.
showTitle?: boolean;
} = $props(); } = $props();
const feed = new ConversationFeed( const feed = new ConversationFeed(
@@ -54,7 +57,7 @@
}); });
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="@container flex h-full min-h-0 flex-col">
{#if feed.error && !info} {#if feed.error && !info}
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div> <div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
{:else if !info} {:else if !info}
@@ -71,15 +74,18 @@
liveState={feed.live.state} liveState={feed.live.state}
onChanged={refresh} onChanged={refresh}
{onOpen} {onOpen}
{showTitle}
/> />
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}> <Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6"> <div class="border-b px-3 py-2 @md:px-6">
<Tabs.List class="w-fit max-w-full">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger> <Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger> <Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger> <Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger value="meta">Meta</Tabs.Trigger> <Tabs.Trigger value="meta">Meta</Tabs.Trigger>
</Tabs.List> </Tabs.List>
</div>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat"> <Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView <ChatView
{client} {client}
@@ -89,7 +95,7 @@
/> />
</Tabs.Content> </Tabs.Content>
<Tabs.Content <Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6" class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="activity" value="activity"
> >
<ActivityFeed <ActivityFeed
@@ -100,13 +106,13 @@
/> />
</Tabs.Content> </Tabs.Content>
<Tabs.Content <Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6" class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="raw" value="raw"
> >
<RawEntries {client} conversationId={id} /> <RawEntries {client} conversationId={id} />
</Tabs.Content> </Tabs.Content>
<Tabs.Content <Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6" class="min-h-0 flex-1 overflow-y-auto px-3 py-3 @md:px-6"
value="meta" value="meta"
> >
{@render meta()} {@render meta()}
+51
View File
@@ -0,0 +1,51 @@
import { getContext, setContext } from "svelte";
export type LinkKind = "internal" | "external";
// What the panel needs from whoever mounts it. The browser admin renders
// markdown itself and hands [[links]] to the obsidian:// scheme; the
// Obsidian plugin renders through MarkdownRenderer and opens notes in
// place. Anything the host leaves undefined falls back to the browser way.
export interface PanelHost {
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
name: "browser" | "obsidian";
openLink: (target: string, kind: LinkKind) => void;
// Opens the note a conversation lives in (its markdown binding).
openNote?: (path: string) => void;
// Hover-less device: row actions stay visible instead of appearing on hover.
touch: boolean;
}
const KEY = Symbol("beaver-panel-host");
export function providePanelHost(host: PanelHost): PanelHost {
setContext(KEY, host);
return host;
}
export function usePanelHost(): PanelHost {
return getContext<PanelHost | undefined>(KEY) ?? browserHost();
}
let browser: PanelHost | null = null;
export function browserHost(): PanelHost {
if (browser) {
return browser;
}
browser = {
name: "browser",
openLink(target, kind) {
if (kind === "internal") {
window.open(`obsidian://open?file=${encodeURIComponent(target)}`);
return;
}
window.open(target, "_blank", "noopener");
},
touch:
typeof window !== "undefined" &&
window.matchMedia("(hover: none)").matches,
};
return browser;
}
+106
View File
@@ -0,0 +1,106 @@
import type { ApiClient } from "$lib/api/client";
import { LiveStream } from "$lib/api/live.svelte";
import type { BusEvent, ConversationSummary } from "$lib/api/types";
const RELOAD_DEBOUNCE_MS = 1500;
const PAGE = 500;
// The conversation index kept fresh by ``/api/events``: rows land as
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
// without a refetch, and anything that moves the queue reloads once.
// Shared by the admin's rail and the panel's picker.
export class ConversationIndex {
conversations = $state<ConversationSummary[]>([]);
loaded = $state(false);
readonly live: LiveStream;
protected readonly client: () => ApiClient | null;
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
constructor(client: () => ApiClient | null) {
this.client = client;
this.live = new LiveStream(client, "/api/events", {
onEvent: (event) => this.apply(event),
prepare: () => this.load(),
});
}
get running(): ConversationSummary[] {
return this.conversations.filter((row) => row.running_turn);
}
get open(): ConversationSummary[] {
return this.conversations.filter((row) => row.status === "open");
}
byId(id: string): ConversationSummary | undefined {
return this.conversations.find((row) => row.id === id);
}
async load(): Promise<void> {
const client = this.client();
if (!client) {
return;
}
const list = await client.conversations({ limit: PAGE });
this.conversations = list.conversations;
this.loaded = true;
}
apply(event: BusEvent): void {
switch (event.type) {
case "conversation.created":
case "conversation.updated": {
if (typeof event.id === "string") {
this.upsert(event as unknown as ConversationSummary);
}
return;
}
case "turn.start":
case "turn.end": {
const row = event.conversation_id
? this.byId(event.conversation_id)
: undefined;
if (row) {
row.running_turn =
event.type === "turn.start" ? (event.turn_id ?? null) : null;
row.last_activity_at = event.ts;
}
return;
}
case "message.queued":
case "inject.queued":
case "reply": {
this.reloadSoon();
return;
}
default:
}
}
start(): void {
this.live.start();
}
stop(): void {
this.live.stop();
}
private upsert(row: ConversationSummary): void {
const index = this.conversations.findIndex((c) => c.id === row.id);
if (index >= 0) {
this.conversations[index] = { ...this.conversations[index], ...row };
} else {
this.conversations.unshift(row);
}
}
private reloadSoon(): void {
if (this.reloadTimer) {
return;
}
this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;
this.load().catch(() => undefined);
}, RELOAD_DEBOUNCE_MS);
}
}
+53 -3
View File
@@ -1,14 +1,64 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import { renderMarkdown } from "./markdown"; import { usePanelHost } from "./host";
import { linkOf, renderMarkdown } from "./markdown";
let { text, class: className = "" }: { text: string; class?: string } = let { text, class: className = "" }: { text: string; class?: string } =
$props(); $props();
const html = $derived(renderMarkdown(text)); const host = usePanelHost();
const html = $derived(host.markdown ? "" : renderMarkdown(text));
// Links inside rendered markdown are the only interactive things here:
// real anchors, reached by pointer or by Enter on the focused link. The
// handlers ride on the container so re-rendered HTML keeps them.
function follow(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent && event.key !== "Enter") {
return;
}
const link = linkOf(event);
if (!link) {
return;
}
event.preventDefault();
event.stopPropagation();
host.openLink(link.target, link.kind);
}
function links(node: HTMLElement) {
node.addEventListener("click", follow);
node.addEventListener("keydown", follow);
return {
destroy() {
node.removeEventListener("click", follow);
node.removeEventListener("keydown", follow);
},
};
}
function render(node: HTMLElement, value: string) {
let dispose = host.markdown?.(node, value);
return {
destroy() {
dispose?.();
},
update(next: string) {
dispose?.();
dispose = host.markdown?.(node, next);
},
};
}
</script> </script>
<div class={cn("prose prose-sm max-w-none", className)}> {#if host.markdown}
<div
class={cn("beaver-md markdown-rendered", className)}
use:links
use:render={text}
></div>
{:else}
<div class={cn("prose prose-sm max-w-none", className)} use:links>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown --> <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown -->
{@html html} {@html html}
</div> </div>
{/if}
+62 -5
View File
@@ -1,18 +1,75 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { marked } from "marked"; import { marked, type TokenizerAndRendererExtension } from "marked";
marked.use({ breaks: true, gfm: true }); const WIKILINK = /^\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/;
const ESCAPES: Record<string, string> = {
'"': "&quot;",
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
const ESCAPABLE = /[&<>"]/g;
function escapeHtml(text: string): string {
return text.replace(ESCAPABLE, (ch) => ESCAPES[ch]);
}
// ``[[note]]`` and ``[[note|alias]]`` render like Obsidian's own internal
// links, so one click handler serves both renderers.
const wikilink: TokenizerAndRendererExtension = {
level: "inline",
name: "wikilink",
renderer(token) {
const target = String(token.target);
const text = String(token.text);
return `<a class="internal-link" data-href="${escapeHtml(target)}" href="#">${escapeHtml(text)}</a>`;
},
start(src) {
return src.indexOf("[[");
},
tokenizer(src) {
const match = WIKILINK.exec(src);
if (!match) {
return;
}
const target = match[1].trim();
return {
raw: match[0],
target,
text: (match[2] ?? target).trim(),
type: "wikilink",
};
},
};
marked.use({ breaks: true, extensions: [wikilink], gfm: true });
DOMPurify.addHook("afterSanitizeAttributes", (node) => { DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") { if (node.tagName === "A" && !node.classList.contains("internal-link")) {
node.setAttribute("target", "_blank"); node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener"); node.setAttribute("rel", "noopener");
} }
}); });
// The one place chat text becomes HTML. The Obsidian panel swaps this for // The browser's markdown path; the Obsidian panel renders through the
// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify. // host's MarkdownRenderer instead (see host.ts).
export function renderMarkdown(text: string): string { export function renderMarkdown(text: string): string {
const html = marked.parse(text, { async: false }); const html = marked.parse(text, { async: false });
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] }); return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] });
} }
// Resolves the clicked anchor, if any, to what the host should open.
export function linkOf(
event: Event
): { target: string; kind: "internal" | "external" } | null {
const anchor = (event.target as HTMLElement | null)?.closest("a");
if (!anchor) {
return null;
}
if (anchor.classList.contains("internal-link")) {
const target = anchor.dataset.href ?? anchor.getAttribute("href") ?? "";
return target && target !== "#" ? { kind: "internal", target } : null;
}
const href = anchor.getAttribute("href") ?? "";
return href ? { kind: "external", target: href } : null;
}
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts" module>
import type { Component } from "svelte";
// The dropdown and the context menu share this shape; only the primitive
// behind it differs.
export interface MenuKit {
// biome-ignore lint/suspicious/noExplicitAny: both bits-ui kits fit, neither shares a type
CheckboxItem: Component<any>;
// biome-ignore lint/suspicious/noExplicitAny: see above
Item: Component<any>;
// biome-ignore lint/suspicious/noExplicitAny: see above
Separator: Component<any>;
}
</script>
<script lang="ts">
import type { MenuAction } from "./actions.svelte";
let { kit, actions }: { kit: MenuKit; actions: MenuAction[] } = $props();
</script>
{#each actions as action, index (action.label)}
{#if action.gap && index > 0}
<kit.Separator />
{/if}
{#if action.checked === undefined}
<kit.Item
disabled={action.disabled}
onSelect={() => action.run()}
variant={action.danger ? "destructive" : "default"}
>
{#if action.icon}
<action.icon class="size-4" />
{/if}
{action.label}
</kit.Item>
{:else}
<kit.CheckboxItem
checked={action.checked}
disabled={action.disabled}
onCheckedChange={() => action.run()}
>
{#if action.icon}
<action.icon class="size-4" />
{/if}
{action.label}
</kit.CheckboxItem>
{/if}
{/each}
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import Link2Icon from "@lucide/svelte/icons/link-2";
import Link2OffIcon from "@lucide/svelte/icons/link-2-off";
import type { ApiClient } from "$lib/api/client";
import KindBadge from "$lib/components/kind-badge.svelte";
import LiveDot from "$lib/components/live-dot.svelte";
import { Button } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover";
import { clip, shortId } from "$lib/format";
import ConversationPicker from "./conversation-picker.svelte";
import type { ConversationIndex } from "./index.svelte";
let {
client,
index,
selected = null,
onOpen,
follow = $bindable(false),
showFollow = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
onOpen: (id: string) => void;
follow?: boolean;
showFollow?: boolean;
} = $props();
const TITLE_MAX = 48;
let switcherOpen = $state(false);
const current = $derived(selected ? index.byId(selected) : undefined);
const running = $derived(index.running.length);
</script>
<div class="flex items-center gap-1 border-b px-2 py-1.5">
<Popover.Root bind:open={switcherOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
aria-label="Switch conversation"
class="row-hover flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 text-left text-sm aria-expanded:bg-muted"
type="button"
>
{#if current}
<KindBadge kind={current.kind} />
<span class="min-w-0 flex-1 truncate font-medium">
{current.title
? clip(current.title, TITLE_MAX)
: `${current.kind} · ${shortId(current.id)}`}
</span>
{:else if selected}
<span class="tabular min-w-0 flex-1 truncate font-medium">
{shortId(selected)}
</span>
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">
Pick a conversation
</span>
{/if}
{#if running > 0 && !current?.running_turn}
<span
class="tabular shrink-0 rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running elsewhere"
>
{running}
</span>
{/if}
<ChevronDownIcon class="size-3.5 shrink-0 text-icon" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content
align="start"
class="w-[min(24rem,calc(100vw-1rem))] gap-0 overflow-hidden p-0"
sideOffset={6}
>
<div class="flex max-h-[min(70vh,32rem)] flex-col">
<ConversationPicker
autofocus
{client}
{index}
onOpen={(id) => {
switcherOpen = false;
onOpen(id);
}}
{selected}
/>
</div>
</Popover.Content>
</Popover.Root>
<LiveDot
class="px-1"
detail={index.live.detail}
label={false}
state={index.live.state}
/>
{#if showFollow}
<Button
aria-label="Follow the active note"
aria-pressed={follow}
onclick={() => {
follow = !follow;
}}
size="icon-sm"
title={follow
? "Following the active note - click to pin this conversation"
: "Pinned - click to follow the active note"}
variant={follow ? "secondary" : "ghost"}
>
{#if follow}
<Link2Icon class="size-4" />
{:else}
<Link2OffIcon class="size-4" />
{/if}
</Button>
{/if}
</div>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import EmptyState from "$lib/components/empty-state.svelte";
import ConversationPicker from "./conversation-picker.svelte";
import ConversationView from "./conversation-view.svelte";
import type { ConversationIndex } from "./index.svelte";
import PanelBar from "./panel-bar.svelte";
let {
client,
index,
selected = $bindable(null),
follow = $bindable(false),
showFollow = false,
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
follow?: boolean;
showFollow?: boolean;
} = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does.
const RAIL_FROM_PX = 768;
let width = $state(0);
const wide = $derived(width >= RAIL_FROM_PX);
onMount(() => {
index.start();
return () => index.stop();
});
function open(id: string) {
selected = id;
}
</script>
<!--
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
THESIS: the agent's terminal, not a chat app. The thread is always on screen; picking a
conversation is a header switch, never a screen of its own. Refuses the messenger's list-first
layout and the admin's chrome.
OWN-WORLD: Obsidian's own variables through .beaver-root - its font, radii, accent, and status
colors; hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
STORY: the operator opens a note, the panel follows to its conversation, they watch tools and
subagents stream, answer a question, branch off with a chosen seed, send the branch back to
Telegram, and close a deep chat with memory on or off - all from the sidedock.
FIRST VIEWPORT: narrow column: the switcher (kind badge, title, live dot, follow toggle) over
the thread header, tabs, the live thread and the composer pinned at the bottom. Wide tab:
the same with the grouped conversation rail on the left. Primary action: write to the agent.
FORM: two-pane operator console; pinned by the brief, no roll.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
the verdict, and DESIGN.md
-->
<div
class="beaver-panel @container flex h-full min-h-0 flex-col bg-background text-foreground"
bind:clientWidth={width}
>
<div class="flex min-h-0 flex-1">
{#if wide}
<aside class="flex w-72 shrink-0 flex-col border-r bg-sidebar/40">
<ConversationPicker
{client}
{index}
onOpen={open}
{selected}
{showFollow}
bind:follow
/>
</aside>
{/if}
<section class="flex min-w-0 flex-1 flex-col">
{#if !wide}
<PanelBar
{client}
{index}
onOpen={open}
{selected}
{showFollow}
bind:follow
/>
{/if}
{#if selected}
{#key selected}
<ConversationView
{client}
id={selected}
onOpen={open}
showTitle={wide}
/>
{/key}
{:else}
<div class="flex flex-1 items-start justify-center p-4 @md:p-6">
<EmptyState
class="w-full max-w-md"
hint={showFollow
? "Open a note with conversation_id in its frontmatter, or pick one from the list."
: "Pick one from the list: the master, a branch, a deep chat."}
title="No conversation on screen"
/>
</div>
{/if}
</section>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
/* Palette-neutral vocabulary shared by the browser admin and the Obsidian
panel: kind and outcome hues resolve to whatever the host theme sets,
the utilities are the ledger grammar, the two animations are the only
motion the panel owns. */
@theme inline {
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-kind-master: var(--signal);
--color-kind-branch: var(--status-reply);
--color-kind-deep: var(--status-meeting);
--color-kind-job: var(--status-work);
--color-kind-fork: var(--status-skip);
--color-ok: var(--status-done);
--color-warn: var(--warn);
--color-link: var(--link);
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
@utility touch-shown {
@media (hover: none) {
opacity: 1;
}
}
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility row-hover {
transition: background-color 150ms ease-out;
&:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
}
@utility ledger-grid {
display: grid;
column-gap: 0.75rem;
align-items: center;
}
+8 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import MessageCircleQuestionIcon from "@lucide/svelte/icons/message-circle-question";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client"; import type { ApiClient } from "$lib/api/client";
import type { PendingQuestion } from "$lib/api/types"; import type { PendingQuestion } from "$lib/api/types";
@@ -37,12 +38,15 @@
<section <section
class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-primary/5 p-3" class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-primary/5 p-3"
> >
<p class="font-medium text-link text-xs uppercase tracking-wide">
The agent is asking
</p>
{#each question.questions as q, index (index)} {#each question.questions as q, index (index)}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-sm">{q.question}</p> <p class="flex items-start gap-2 font-medium text-sm">
<MessageCircleQuestionIcon
aria-label="The agent asks"
class="mt-0.5 size-4 shrink-0 text-link"
/>
<span>{q.question}</span>
</p>
{#if q.options?.length} {#if q.options?.length}
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each q.options as option (option.label)} {#each q.options as option (option.label)}
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
let {
client,
info,
open = $bindable(false),
onChanged,
}: {
client: ApiClient;
info: Pick<ConversationSummary, "id" | "title">;
open?: boolean;
onChanged: () => void;
} = $props();
let title = $state("");
let busy = $state(false);
$effect(() => {
if (open) {
title = info.title ?? "";
}
});
async function save() {
busy = true;
try {
await client.update(info.id, { title });
open = false;
toast.success("Renamed");
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename</Dialog.Title>
</Dialog.Header>
<form
class="flex flex-col gap-4"
onsubmit={(event) => {
event.preventDefault();
save();
}}
>
<Input aria-label="Title" placeholder="Title" bind:value={title} />
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
+5 -2
View File
@@ -17,17 +17,20 @@
let { children } = $props(); let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`); const isLogin = $derived(page.url.pathname === `${base}/login`);
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
const inConversations = $derived( const inConversations = $derived(
page.url.pathname.startsWith(`${base}/conversations`) page.url.pathname.startsWith(`${base}/conversations`)
); );
let navBefore: boolean | null = null; let navBefore: boolean | null = null;
onMount(() => { onMount(() => {
if (!isPanel) {
session.check(); session.check();
}
}); });
$effect(() => { $effect(() => {
if (!session.ready) { if (!session.ready || isPanel) {
return; return;
} }
if (!(session.user || isLogin)) { if (!(session.user || isLogin)) {
@@ -74,7 +77,7 @@
<Toaster position="bottom-right" richColors /> <Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}> <Tooltip.Provider delayDuration={300}>
{#if isLogin} {#if isLogin || isPanel}
{@render children()} {@render children()}
{:else if session.ready && session.user} {:else if session.ready && session.user}
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row"> <div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
+1 -66
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn-svelte/tailwind.css"; @import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter"; @import "@fontsource-variable/inter";
@import "../lib/panel/panel.css";
@plugin "@tailwindcss/forms"; @plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@@ -178,16 +179,6 @@
} }
} }
@utility touch-shown {
@media (hover: none) {
opacity: 1;
}
}
@utility tabular {
font-variant-numeric: tabular-nums;
}
@utility hue-chip { @utility hue-chip {
color: oklch(0.45 0.13 var(--hue)); color: oklch(0.45 0.13 var(--hue));
background: oklch(0.6 0.09 var(--hue) / 0.12); background: oklch(0.6 0.09 var(--hue) / 0.12);
@@ -197,49 +188,6 @@
} }
} }
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@theme inline {
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-kind-master: var(--signal);
--color-kind-branch: var(--status-reply);
--color-kind-deep: var(--status-meeting);
--color-kind-job: var(--status-work);
--color-kind-fork: var(--status-skip);
--color-ok: var(--status-done);
--color-warn: var(--warn);
--color-link: var(--link);
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
@layer base { @layer base {
* { * {
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent) scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
@@ -281,19 +229,6 @@
} }
} }
@utility row-hover {
transition: background-color 150ms ease-out;
&:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
}
@utility ledger-grid {
display: grid;
column-gap: 0.75rem;
align-items: center;
}
@utility hairline { @utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent); border-color: color-mix(in oklab, var(--border) 100%, transparent);
} }
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount } from "svelte";
import { ApiClient, ApiError } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip";
import { ConversationIndex } from "$lib/panel/index.svelte";
import PanelShell from "$lib/panel/panel-shell.svelte";
// The browser twin of the Obsidian panel: ``?token=`` once, then the
// token lives in localStorage and the conversation id in the hash.
const TOKEN_KEY = "beaver.panel.token";
let client = $state<ApiClient | null>(null);
let index = $state<ConversationIndex | null>(null);
let selected = $state<string | null>(null);
let draft = $state("");
let failure = $state<string | null>(null);
function connect(token: string) {
localStorage.setItem(TOKEN_KEY, token);
const api = new ApiClient(window.location.origin, token, {
onUnauthorized: () => {
localStorage.removeItem(TOKEN_KEY);
failure = "The token was rejected. Paste a fresh one.";
client = null;
index = null;
return Promise.resolve(null);
},
});
client = api;
index = new ConversationIndex(() => api);
failure = null;
}
async function submit() {
const token = draft.trim();
if (!token) {
return;
}
try {
await new ApiClient(window.location.origin, token).agents();
connect(token);
draft = "";
} catch (error) {
failure =
error instanceof ApiError
? `${error.status}: ${error.message}`
: String(error);
}
}
onMount(() => {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get("token");
if (fromQuery) {
url.searchParams.delete("token");
window.history.replaceState(window.history.state, "", url);
}
const token = fromQuery ?? localStorage.getItem(TOKEN_KEY);
if (token) {
connect(token);
}
selected = window.location.hash.slice(1) || null;
});
$effect(() => {
const hash = selected ? `#${selected}` : "";
if (window.location.hash !== hash) {
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${window.location.search}${hash}`
);
}
});
</script>
<svelte:head><title>Panel · Beaver</title></svelte:head>
<Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}>
{#if client && index}
<div class="h-dvh">
<PanelShell {client} {index} bind:selected />
</div>
{:else}
<div class="flex h-dvh items-center justify-center bg-sidebar p-6">
<form
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-background p-6"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
style="box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.25)"
>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-sm bg-primary"></span>
<span class="font-semibold tracking-tight">Beaver panel</span>
</div>
<p class="text-muted-foreground text-sm">
Paste a token with the <code>api</code> scope. It stays in this
browser; open the page with <code>?token=…</code> to hand it over.
</p>
<div class="flex flex-col gap-1.5">
<Label for="panel-token">Token</Label>
<Input
autocomplete="off"
id="panel-token"
placeholder="bvr_…"
type="password"
bind:value={draft}
/>
</div>
{#if failure}
<p class="text-destructive text-sm" role="alert">{failure}</p>
{/if}
<Button disabled={!draft.trim()} type="submit">Open the panel</Button>
</form>
</div>
{/if}
</Tooltip.Provider>