From 3ca58438684cdcbfdef077d9610cf9f5f98d9805 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Aug 2026 00:18:08 +0200 Subject: [PATCH] feat(ui,api): chat view with live tail and autoscroll, conversation rail with last message previews --- src/beaver_gateway/core/injects.py | 18 + src/beaver_gateway/frontends/api/frontend.py | 41 ++- ui/src/lib/api/types.ts | 1 + .../lib/components/conversation-list.svelte | 311 ++++++++++++++++++ ui/src/lib/gateway.svelte.ts | 19 ++ .../{history-view.svelte => chat-view.svelte} | 90 ++++- ui/src/lib/panel/conversation-view.svelte | 72 ++-- ui/src/routes/conversations/+layout.svelte | 27 ++ ui/src/routes/conversations/+page.svelte | 287 +--------------- 9 files changed, 532 insertions(+), 334 deletions(-) create mode 100644 ui/src/lib/components/conversation-list.svelte rename ui/src/lib/panel/{history-view.svelte => chat-view.svelte} (63%) create mode 100644 ui/src/routes/conversations/+layout.svelte diff --git a/src/beaver_gateway/core/injects.py b/src/beaver_gateway/core/injects.py index 1ba5f9f..d1627e5 100644 --- a/src/beaver_gateway/core/injects.py +++ b/src/beaver_gateway/core/injects.py @@ -12,6 +12,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING, Literal +from sqlalchemy import func from sqlmodel import col, select from beaver_gateway.storage.models import InjectQueueItem @@ -105,6 +106,23 @@ class InjectQueue: ) return list(result.all()) + async def latest( + self, conversation_ids: Iterable[int] + ) -> dict[int, InjectQueueItem]: + ids = list(conversation_ids) + if not ids: + return {} + newest = ( + select(func.max(InjectQueueItem.id)) + .where(col(InjectQueueItem.conversation_id).in_(ids)) + .group_by(col(InjectQueueItem.conversation_id)) + ) + async with self._db.session() as session: + result = await session.exec( + select(InjectQueueItem).where(col(InjectQueueItem.id).in_(newest)) + ) + return {row.conversation_id: row for row in result.all()} + async def _mark( self, items: Iterable[InjectQueueItem], diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index 8e0da23..496e97c 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -46,7 +46,13 @@ from beaver_gateway.storage import ( list_tokens, revoke_token, ) -from beaver_gateway.storage.models import Conversation, RateLimit, Token, Usage +from beaver_gateway.storage.models import ( + Conversation, + InjectQueueItem, + RateLimit, + Token, + Usage, +) if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable, Sequence @@ -220,7 +226,16 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa kind=q.get("kind"), limit=query_int(request, "limit", 200), ) - return {"conversations": [conversations.public(r) for r in rows]} + latest = await conversations.queue.latest(cast("int", r.id) for r in rows) + return { + "conversations": [ + { + **conversations.public(r), + "last_item": _queue_item(latest.get(cast("int", r.id))), + } + for r in rows + ] + } @app.post("/api/conversations", status_code=status.HTTP_201_CREATED) async def create_conversation(request: Request) -> dict[str, Any]: @@ -272,14 +287,7 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa conv = await conv_of(public_id) out = await conversations.describe(conv) out["queue"] = [ - { - "id": i.id, - "priority": i.priority, - "origin": i.origin, - "status": i.status, - "created_at": _iso(i.created_at), - "text": i.text[:200], - } + _queue_item(i) for i in await conversations.queue.recent(cast("int", conv.id), limit=20) ] return out @@ -859,6 +867,19 @@ async def _conversation_titles( } +def _queue_item(item: InjectQueueItem | None) -> dict[str, Any] | None: + if item is None: + return None + return { + "id": item.id, + "priority": item.priority, + "origin": item.origin, + "status": item.status, + "created_at": _iso(item.created_at), + "text": item.text[:200], + } + + def _limit_public(row: RateLimit) -> dict[str, Any]: return { "id": row.id, diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 4ab1d2b..58c5bff 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -59,6 +59,7 @@ export interface ConversationSummary { id: string; kind: Kind; last_activity_at: string | null; + last_item?: QueueItem | null; last_user_activity_at: string | null; origin: string; parent_row: number | null; diff --git a/ui/src/lib/components/conversation-list.svelte b/ui/src/lib/components/conversation-list.svelte new file mode 100644 index 0000000..34ca623 --- /dev/null +++ b/ui/src/lib/components/conversation-list.svelte @@ -0,0 +1,311 @@ + + +
+
+ { + kind = value; + }} + type="single" + value={kind} + > + + {kind === "all" ? "any kind" : kind} + + + {#each KINDS as option (option)} + + {/each} + + + { + statusFilter = value; + }} + type="single" + value={statusFilter} + > + + {statusFilter === "all" ? "any status" : statusFilter} + + + {#each STATUSES as option (option)} + + {/each} + + + + +
+
+ {#if gateway.live.state === "failed"} +
+ gateway.start()} + /> +
+ {:else if !gateway.loaded} +
+ + + +
+ {:else if rows.length === 0} +
+ + + +
+ {:else} + + {/if} +
+
+ + + + + New conversation + + It opens in the home window of its kind (a vault file, a Telegram topic) + and stays silent until someone speaks. + + +
+
+ + { + form.kind = value; + form.agent = ""; + }} + type="single" + value={form.kind} + > + {form.kind} + + {#each ["deep", "branch", "master", "job"] as option (option)} + + {/each} + + +
+
+ + { + form.agent = value === "default" ? "" : value; + }} + type="single" + value={form.agent || "default"} + > + + {form.agent || "frontend default"} + + + + {#each agentsForKind as agent (agent.name)} + + {/each} + + +
+
+ + +
+
+ + +
+
+ + + + +
+
diff --git a/ui/src/lib/gateway.svelte.ts b/ui/src/lib/gateway.svelte.ts index 3da494f..a6f1d5d 100644 --- a/ui/src/lib/gateway.svelte.ts +++ b/ui/src/lib/gateway.svelte.ts @@ -8,6 +8,7 @@ import type { 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 @@ -98,10 +99,28 @@ class Gateway { this.applyLimit(event); return; } + case "message.queued": + case "inject.queued": + case "reply": { + this.reloadSoon(); + return; + } default: } } + private reloadTimer: ReturnType | null = null; + + private reloadSoon(): void { + if (this.reloadTimer) { + return; + } + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + this.load().catch(() => undefined); + }, RELOAD_DEBOUNCE_MS); + } + private applyLimit(event: BusEvent): void { if (!this.limits || typeof event.window !== "string") { this.refreshLimits().catch(() => undefined); diff --git a/ui/src/lib/panel/history-view.svelte b/ui/src/lib/panel/chat-view.svelte similarity index 63% rename from ui/src/lib/panel/history-view.svelte rename to ui/src/lib/panel/chat-view.svelte index bface7f..1e1c4f6 100644 --- a/ui/src/lib/panel/history-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -1,4 +1,5 @@ -
+
{ + pinned = nearBottom(); + }} + bind:this={scroller} +> show tool results @@ -77,10 +141,10 @@
- {:else if messages.length === 0} + {:else if messages.length === 0 && tail.length === 0} {:else} {#each messages as message, index (index)} @@ -98,9 +162,7 @@

{block.text} @@ -116,7 +178,9 @@

{clip(resultText(block), RESULT_CLIP)}
{:else if block.type === "thinking"} @@ -127,4 +191,10 @@ {/if} {/each} {/if} + {#if model.question} + + {/if} + {#each tail as turn (turn.id)} + + {/each}
diff --git a/ui/src/lib/panel/conversation-view.svelte b/ui/src/lib/panel/conversation-view.svelte index b1cb6aa..c03f250 100644 --- a/ui/src/lib/panel/conversation-view.svelte +++ b/ui/src/lib/panel/conversation-view.svelte @@ -6,10 +6,10 @@ import * as Tabs from "$lib/components/ui/tabs"; import ActivityFeed from "./activity-feed.svelte"; import BindingsList from "./bindings-list.svelte"; + import ChatView from "./chat-view.svelte"; import Composer from "./composer.svelte"; import { ConversationFeed } from "./conversation.svelte"; import ConversationHeader from "./conversation-header.svelte"; - import HistoryView from "./history-view.svelte"; import QueueList from "./queue-list.svelte"; import RawEntries from "./raw-entries.svelte"; @@ -31,9 +31,8 @@ () => client, untrack(() => id) ); - let tab = $state("activity"); + let tab = $state("chat"); let historyKey = $state(0); - let landed = $state(false); onMount(() => { feed.start(); @@ -55,13 +54,6 @@ }); } }); - - $effect(() => { - if (feed.loaded && !landed) { - landed = true; - tab = feed.model.running ? "activity" : "history"; - } - });
@@ -90,34 +82,44 @@
+ Chat Activity - History Raw - Meta + + Meta + -
- - - - - - - - - - {@render rail()} -
+ + + + + + + + + + + {@render rail()} +
+ import { page } from "$app/state"; + import ConversationList from "$lib/components/conversation-list.svelte"; + import { cn } from "$lib/utils"; + + let { children } = $props(); + + const selected = $derived(page.params.id ?? null); + + +Conversations · Beaver + +
+
+ +
+
+ {@render children()} +
+
diff --git a/ui/src/routes/conversations/+page.svelte b/ui/src/routes/conversations/+page.svelte index c317a71..cda986f 100644 --- a/ui/src/routes/conversations/+page.svelte +++ b/ui/src/routes/conversations/+page.svelte @@ -1,287 +1,16 @@ -Conversations · Beaver - - - { - kind = value; - }} - type="single" - value={kind} - > - - {kind === "all" ? "any kind" : kind} - - - {#each KINDS as option (option)} - - {/each} - - - { - statusFilter = value; - }} - type="single" - value={statusFilter} - > - - {statusFilter === "all" ? "any status" : statusFilter} - - - {#each STATUSES as option (option)} - - {/each} - - - - {#snippet actions()} - - {/snippet} - - -
- {#if gateway.live.state === "failed"} -
- gateway.start()} - /> -
- {:else if !gateway.loaded} -
- - - -
- {:else if rows.length === 0} -
- - - -
+
+ {#if running > 0} + {running} + running - pick one on the left. {:else} - + Pick a conversation on the left. {/if}
- - - - - New conversation - - It opens in the home window of its kind (a vault file, a Telegram topic) - and stays silent until someone speaks. - - -
-
- - { - form.kind = value; - form.agent = ""; - }} - type="single" - value={form.kind} - > - {form.kind} - - {#each ["deep", "branch", "master", "job"] as option (option)} - - {/each} - - -
-
- - { - form.agent = value === "default" ? "" : value; - }} - type="single" - value={form.agent || "default"} - > - {form.agent || "frontend default"} - - - {#each agentsForKind as agent (agent.name)} - - {/each} - - -
-
- - -
-
- - -
-
- - - - -
-