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}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-