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:
+489
-46
@@ -1,54 +1,452 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-08-28T00:00:00Z",
|
||||
"generatedAt": "2026-08-29T00:00:00Z",
|
||||
"title": "Design System: Beaver admin",
|
||||
"extensions": {
|
||||
"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)"] },
|
||||
"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)"] },
|
||||
"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)"] }
|
||||
"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)"
|
||||
]
|
||||
},
|
||||
"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)"
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"headline": { "displayName": "Headline", "purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking." },
|
||||
"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." }
|
||||
"headline": {
|
||||
"displayName": "Headline",
|
||||
"purpose": "Page titles in page-header.svelte; the sidebar wordmark shares weight and tracking."
|
||||
},
|
||||
"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": [
|
||||
{ "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": [
|
||||
{ "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": "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." }
|
||||
{
|
||||
"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": "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": [
|
||||
{ "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": "lg", "value": "1024px", "purpose": "Thread meta rail (18rem) and two-column dashboard sections." }
|
||||
]
|
||||
{
|
||||
"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": "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": [
|
||||
{
|
||||
@@ -153,12 +551,51 @@
|
||||
"Light and dark modes, both tinted toward hue 340; dark borders are 10% alpha overlays"
|
||||
],
|
||||
"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 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 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 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": [
|
||||
"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 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 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": [
|
||||
"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 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 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.
|
||||
@@ -497,14 +497,20 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
|
||||
conv = await conv_of(public_id)
|
||||
data = await body_of(request)
|
||||
frontend = text_of(data, "frontend")
|
||||
external_id = text_of(data, "external_id")
|
||||
external_id = data.get("external_id")
|
||||
try:
|
||||
if isinstance(external_id, str) and external_id.strip():
|
||||
await conversations.bind(
|
||||
conv,
|
||||
frontend=frontend,
|
||||
external_id=external_id,
|
||||
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:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
return await conversations.describe(conv)
|
||||
|
||||
@@ -35,6 +35,10 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram.inbox")
|
||||
ALLOWED_UPDATES = ("message", "callback_query")
|
||||
|
||||
|
||||
def _unset(_value: object) -> None:
|
||||
"""Unset aiogram ``Default`` sentinels (link previews) are stored as null."""
|
||||
|
||||
|
||||
class Inbox:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -85,7 +89,9 @@ class Inbox:
|
||||
async def _store(self, update: Update) -> None:
|
||||
row = TelegramUpdate(
|
||||
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:
|
||||
session.add(row)
|
||||
|
||||
@@ -448,3 +448,45 @@ async def test_close_distills_a_deep_chat(world: World) -> None:
|
||||
f"/conversations/{branch.external_id}/close", headers=HEADERS
|
||||
)
|
||||
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
|
||||
|
||||
@@ -593,3 +593,24 @@ def test_render_helpers() -> None:
|
||||
assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
|
||||
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
|
||||
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
@@ -12,7 +12,7 @@
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@lucide/svelte": "^1.35.0",
|
||||
"@lucide/svelte": "^1.37.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@lucide/svelte": "^1.35.0",
|
||||
"@lucide/svelte": "^1.37.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -245,14 +245,15 @@ export class ApiClient {
|
||||
return this.post(`/api/conversations/${id}/close`);
|
||||
}
|
||||
|
||||
// Without ``externalId`` the frontend opens a fresh window (a new topic).
|
||||
bind(
|
||||
id: string,
|
||||
frontend: string,
|
||||
externalId: string,
|
||||
externalId: string | null,
|
||||
visible: boolean
|
||||
): Promise<ConversationInfo> {
|
||||
return this.post(`/api/conversations/${id}/bind`, {
|
||||
external_id: externalId,
|
||||
external_id: externalId ?? undefined,
|
||||
frontend,
|
||||
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,
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -1,124 +1,54 @@
|
||||
import { LiveStream } from "./api/live.svelte";
|
||||
import type {
|
||||
BusEvent,
|
||||
ConversationSummary,
|
||||
LimitsResponse,
|
||||
LimitWindow,
|
||||
} from "./api/types";
|
||||
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
|
||||
import { ConversationIndex } from "./panel/index.svelte";
|
||||
import { session } from "./session.svelte";
|
||||
|
||||
const TAPE_SIZE = 120;
|
||||
const RELOAD_DEBOUNCE_MS = 1500;
|
||||
const QUIET = new Set(["stream", "hello"]);
|
||||
|
||||
// Gateway-wide live state: the conversation index kept fresh by
|
||||
// ``/api/events``, the last non-chatty events as a tape, and the quota
|
||||
// windows updated the moment the SDK reports them.
|
||||
class Gateway {
|
||||
conversations = $state<ConversationSummary[]>([]);
|
||||
// Gateway-wide live state for the admin: the conversation index, the last
|
||||
// non-chatty events as a tape, and the quota windows updated the moment
|
||||
// the SDK reports them.
|
||||
class Gateway extends ConversationIndex {
|
||||
tape = $state<BusEvent[]>([]);
|
||||
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[] {
|
||||
return this.conversations.filter((row) => row.running_turn);
|
||||
constructor() {
|
||||
super(() => session.client);
|
||||
}
|
||||
|
||||
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 } = session;
|
||||
override async load(): Promise<void> {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const [list, limits] = await Promise.all([
|
||||
client.conversations({ limit: 500 }),
|
||||
const [, limits] = await Promise.all([
|
||||
super.load(),
|
||||
client.limits().catch(() => null),
|
||||
]);
|
||||
this.conversations = list.conversations;
|
||||
if (limits) {
|
||||
this.limits = limits;
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async refreshLimits(): Promise<void> {
|
||||
const { client } = session;
|
||||
const client = this.client();
|
||||
if (client) {
|
||||
this.limits = await client.limits();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
apply(event: BusEvent): void {
|
||||
override apply(event: BusEvent): void {
|
||||
if (!QUIET.has(event.type)) {
|
||||
this.tape.unshift(event);
|
||||
if (this.tape.length > TAPE_SIZE) {
|
||||
this.tape.length = TAPE_SIZE;
|
||||
}
|
||||
}
|
||||
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 "rate_limit": {
|
||||
if (event.type === "rate_limit") {
|
||||
this.applyLimit(event);
|
||||
return;
|
||||
}
|
||||
case "message.queued":
|
||||
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);
|
||||
super.apply(event);
|
||||
}
|
||||
|
||||
private applyLimit(event: BusEvent): void {
|
||||
@@ -140,14 +70,6 @@ class Gateway {
|
||||
this.refreshLimits().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.live.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.live.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const gateway = new Gateway();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -124,7 +124,7 @@
|
||||
</script>
|
||||
|
||||
<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={() => {
|
||||
pinned = nearBottom();
|
||||
}}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</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
|
||||
>
|
||||
<Button
|
||||
@@ -122,7 +122,7 @@
|
||||
>
|
||||
<SendHorizontalIcon class="size-4" />
|
||||
Send
|
||||
<kbd class="hidden text-[10px] opacity-70 sm:inline">⌘↩</kbd>
|
||||
<kbd class="hidden text-[10px] opacity-70 @md:inline">⌘↩</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
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 { LiveState } from "$lib/api/live.svelte";
|
||||
import type { ConversationInfo } from "$lib/api/types";
|
||||
@@ -10,114 +7,67 @@
|
||||
import LiveDot from "$lib/components/live-dot.svelte";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
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 BranchDialog from "./branch-dialog.svelte";
|
||||
import ConversationMenu from "./conversation-menu.svelte";
|
||||
|
||||
let {
|
||||
client,
|
||||
info,
|
||||
liveState,
|
||||
liveDetail = null,
|
||||
href,
|
||||
href = null,
|
||||
onChanged,
|
||||
onOpen,
|
||||
showTitle = true,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
info: ConversationInfo;
|
||||
liveState: LiveState;
|
||||
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;
|
||||
onOpen: (id: string) => void;
|
||||
showTitle?: boolean;
|
||||
} = $props();
|
||||
|
||||
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 windows = $derived(info.bindings.filter((b) => b.visible));
|
||||
const queued = $derived(
|
||||
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>
|
||||
|
||||
<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">
|
||||
{#if showTitle}
|
||||
<KindBadge kind={info.kind} />
|
||||
<h1 class="min-w-0 truncate font-semibold text-base tracking-tight">
|
||||
{info.title || `${info.kind} ${shortId(info.id)}`}
|
||||
</h1>
|
||||
{/if}
|
||||
<StatusPill status={info.running_turn ? "running" : info.status} />
|
||||
{#if info.pending_question}
|
||||
<span class="font-medium text-link text-xs">question pending</span>
|
||||
{/if}
|
||||
<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
|
||||
disabled={busy || info.status !== "open"}
|
||||
aria-label="Branch off this conversation"
|
||||
disabled={info.status !== "open"}
|
||||
onclick={() => {
|
||||
branchOpen = true;
|
||||
}}
|
||||
@@ -125,88 +75,44 @@
|
||||
variant="outline"
|
||||
>
|
||||
<GitBranchIcon class="size-4" />
|
||||
Branch
|
||||
<span class="hidden @md:inline">Branch</span>
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
aria-label="More actions"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<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>
|
||||
<ConversationMenu
|
||||
{client}
|
||||
current
|
||||
id={info.id}
|
||||
{info}
|
||||
mode="button"
|
||||
{onChanged}
|
||||
{onOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Below @md the column is the live thread's; the rest of this lives in Meta. -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
|
||||
>
|
||||
<span>{info.agent}</span>
|
||||
<span>via {info.origin}</span>
|
||||
<span class="hidden @md:inline">{info.agent}</span>
|
||||
<span class="hidden @md:inline">via {info.origin}</span>
|
||||
{#if info.parent}
|
||||
{#if href}
|
||||
<a class="text-link hover:underline" href={href(info.parent)}>
|
||||
parent {shortId(info.parent)}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="text-link hover:underline"
|
||||
onclick={() => info.parent && onOpen(info.parent)}
|
||||
type="button"
|
||||
>
|
||||
parent {shortId(info.parent)}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="tabular" title={info.id}>{shortId(info.id)}</span>
|
||||
<span class="tabular">
|
||||
{/if}
|
||||
<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.busy ? " · busy" : ""}
|
||||
</span>
|
||||
@@ -225,7 +131,7 @@
|
||||
</div>
|
||||
{#if windows.length > 0}
|
||||
<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)}
|
||||
<span class="truncate" title="{window.frontend}: {window.external_id}">
|
||||
@@ -237,97 +143,4 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<Dialog.Root 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>
|
||||
<BranchDialog {client} onOpened={onOpen} parent={info} bind:open={branchOpen} />
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -16,13 +16,16 @@
|
||||
let {
|
||||
client,
|
||||
id,
|
||||
href,
|
||||
href = null,
|
||||
onOpen,
|
||||
showTitle = true,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
id: string;
|
||||
href: (id: string) => string;
|
||||
href?: ((id: string) => string) | null;
|
||||
onOpen: (id: string) => void;
|
||||
// Off when a switcher above the thread already names it.
|
||||
showTitle?: boolean;
|
||||
} = $props();
|
||||
|
||||
const feed = new ConversationFeed(
|
||||
@@ -54,7 +57,7 @@
|
||||
});
|
||||
</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}
|
||||
<div class="p-4"><ErrorNote message={feed.error} retry={refresh} /></div>
|
||||
{:else if !info}
|
||||
@@ -71,15 +74,18 @@
|
||||
liveState={feed.live.state}
|
||||
onChanged={refresh}
|
||||
{onOpen}
|
||||
{showTitle}
|
||||
/>
|
||||
<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.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="activity">Activity</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
|
||||
<ChatView
|
||||
{client}
|
||||
@@ -89,7 +95,7 @@
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<ActivityFeed
|
||||
@@ -100,13 +106,13 @@
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<RawEntries {client} conversationId={id} />
|
||||
</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"
|
||||
>
|
||||
{@render meta()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,64 @@
|
||||
<script lang="ts">
|
||||
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 } =
|
||||
$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>
|
||||
|
||||
<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 -->
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,18 +1,75 @@
|
||||
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> = {
|
||||
'"': """,
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
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) => {
|
||||
if (node.tagName === "A") {
|
||||
if (node.tagName === "A" && !node.classList.contains("internal-link")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener");
|
||||
}
|
||||
});
|
||||
|
||||
// The one place chat text becomes HTML. The Obsidian panel swaps this for
|
||||
// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify.
|
||||
// The browser's markdown path; the Obsidian panel renders through the
|
||||
// host's MarkdownRenderer instead (see host.ts).
|
||||
export function renderMarkdown(text: string): string {
|
||||
const html = marked.parse(text, { async: false });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import MessageCircleQuestionIcon from "@lucide/svelte/icons/message-circle-question";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { PendingQuestion } from "$lib/api/types";
|
||||
@@ -37,12 +38,15 @@
|
||||
<section
|
||||
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)}
|
||||
<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}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each q.options as option (option.label)}
|
||||
|
||||
@@ -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>
|
||||
@@ -17,17 +17,20 @@
|
||||
let { children } = $props();
|
||||
|
||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
|
||||
const inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!isPanel) {
|
||||
session.check();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!session.ready) {
|
||||
if (!session.ready || isPanel) {
|
||||
return;
|
||||
}
|
||||
if (!(session.user || isLogin)) {
|
||||
@@ -74,7 +77,7 @@
|
||||
<Toaster position="bottom-right" richColors />
|
||||
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
{#if isLogin}
|
||||
{#if isLogin || isPanel}
|
||||
{@render children()}
|
||||
{:else if session.ready && session.user}
|
||||
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn-svelte/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "../lib/panel/panel.css";
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
@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 {
|
||||
color: oklch(0.45 0.13 var(--hue));
|
||||
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 {
|
||||
* {
|
||||
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 {
|
||||
border-color: color-mix(in oklab, var(--border) 100%, transparent);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user