From 1e70f2d12274da833035912206b1ac0b1ee57ef1 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 28 May 2026 09:42:55 +0900 Subject: feat: add agent status indicator in chat input, db tabs tests, and service management script --- bin/service | 85 +++++++++ packages/core/tests/db/tabs.test.ts | 199 +++++++++++++++++++++ .../frontend/src/lib/components/ChatInput.svelte | 15 ++ 3 files changed, 299 insertions(+) create mode 100755 bin/service create mode 100644 packages/core/tests/db/tabs.test.ts diff --git a/bin/service b/bin/service new file mode 100755 index 0000000..eaa63eb --- /dev/null +++ b/bin/service @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cmd="${1:-help}" +shift 2>/dev/null || true + +usage() { + cat < + +Commands: + install Install the dispatch packages (systemd user units + + application files in /opt/dispatch). + start Start dispatch-api and dispatch-frontend. + If already running, restart instead. + Prints the frontend URL when ready. + stop Stop dispatch-api and dispatch-frontend. + status Show status of both services. + logs Tail journal output for both services. + +After install, configure /etc/dispatch/dispatch-api.conf before starting. +EOF +} + +install() { + echo "==> Building fresh package..." + "$PROJECT_DIR/bin/build-pkg" + + echo "" + echo "==> Installing..." + "$PROJECT_DIR/bin/install-pkg" + + systemctl --user daemon-reload + echo "" + echo "==> Installed. Edit /etc/dispatch/dispatch-api.conf, then run:" + echo " bin/service start" +} + +have_units() { + systemctl --user cat dispatch-api.service &>/dev/null +} + +start() { + if ! have_units; then + echo "dispatch services not installed. Run: bin/service install" + return 1 + fi + local api_active frontend_active + api_active=$(systemctl --user is-active dispatch-api 2>/dev/null || echo "inactive") + frontend_active=$(systemctl --user is-active dispatch-frontend 2>/dev/null || echo "inactive") + + if [ "$api_active" = "active" ] || [ "$frontend_active" = "active" ]; then + echo "==> Services already running (api=$api_active frontend=$frontend_active) — restarting" + systemctl --user restart dispatch-api dispatch-frontend + else + systemctl --user start dispatch-api dispatch-frontend + fi + echo "dispatch-api + dispatch-frontend $( [ "$api_active" = "active" ] || [ "$frontend_active" = "active" ] && echo restarted || echo started )" + echo " → http://localhost:18391" +} + +status() { + if ! have_units; then + echo "dispatch services not installed." + return 1 + fi + systemctl --user status dispatch-api dispatch-frontend --no-pager 2>/dev/null || true +} + +logs() { + journalctl --user -u dispatch-api -u dispatch-frontend -f "$@" +} + +case "$cmd" in + install) install ;; + start) start ;; + stop) stop ;; + status) status ;; + logs) logs "$@" ;; + help|--help|-h) usage ;; + *) echo "Unknown command: $cmd"; echo; usage; exit 1 ;; +esac diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts new file mode 100644 index 0000000..e8de3ce --- /dev/null +++ b/packages/core/tests/db/tabs.test.ts @@ -0,0 +1,199 @@ +import { Database } from "bun:sqlite"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +/** In-memory database instance assigned in beforeAll. */ +let memDb: Database; + +// Mock getDatabase to return the in-memory database. The factory +// captures memDb by reference — it won't be dereferenced until a test +// calls getDescendantIds (or another exported function), by which +// point beforeAll will have initialised the variable. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => memDb), +})); + +// Dynamic import AFTER the mock is registered (hoisted) so the +// module-under-test sees the mocked getDatabase. +const { + getDescendantIds, + createTab, + archiveTab, + getTab, +} = await import("../../src/db/tabs.js"); + +beforeAll(() => { + memDb = new Database(":memory:"); + memDb.run(`CREATE TABLE tabs ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + key_id TEXT, + model_id TEXT, + parent_tab_id TEXT, + status TEXT NOT NULL DEFAULT 'idle', + is_open INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); +}); + +afterAll(() => { + memDb.close(); +}); + +/** Wipe the tabs table between tests so every test starts clean. */ +beforeEach(() => { + memDb.run("DELETE FROM tabs"); +}); + +// --------------------------------------------------------------------------- +// getDescendantIds +// --------------------------------------------------------------------------- +describe("getDescendantIds", () => { + it("returns only the id when the tab has no children", () => { + const now = Date.now(); + memDb.run( + `INSERT INTO tabs (id, title, status, is_open, position, created_at, updated_at) + VALUES ('root', 'Root', 'idle', 1, 0, $now, $now)`, + { $now: now }, + ); + + const ids = getDescendantIds("root"); + expect(ids).toEqual(["root"]); + }); + + it("returns leaf-first order for a linear chain (root → child → grandchild)", () => { + const now = Date.now(); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('root', 'Root', NULL, 'idle', 1, 0, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('child', 'Child', 'root', 'idle', 1, 1, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('grandchild', 'Grandchild', 'child', 'idle', 1, 2, $now, $now)`, + { $now: now }, + ); + + const ids = getDescendantIds("root"); + // Leaves first: grandchild, child, root + expect(ids).toEqual(["grandchild", "child", "root"]); + }); + + it("returns leaf-first for a branching tree", () => { + const now = Date.now(); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('a', 'A', NULL, 'idle', 1, 0, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('b1', 'B1', 'a', 'idle', 1, 1, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('b2', 'B2', 'a', 'idle', 1, 2, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('c1', 'C1', 'b1', 'idle', 1, 3, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('c2', 'C2', 'b1', 'idle', 1, 4, $now, $now)`, + { $now: now }, + ); + + const ids = getDescendantIds("a"); + // BFS: a, b1, b2, c1, c2 → reverse: c2, c1, b2, b1, a + expect(ids).toEqual(["c2", "c1", "b2", "b1", "a"]); + }); + + it("skips archived descendants (is_open = 0)", () => { + const now = Date.now(); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('root', 'Root', NULL, 'idle', 1, 0, $now, $now)`, + { $now: now }, + ); + // Open child of root — should appear + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('open-child', 'Open', 'root', 'idle', 1, 1, $now, $now)`, + { $now: now }, + ); + // Archived child — should be skipped together with its descendants + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('archived-child', 'Archived', 'root', 'idle', 0, 2, $now, $now)`, + { $now: now }, + ); + // Child of archived — data drift, should NOT appear + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('orphan', 'Orphan', 'archived-child', 'idle', 1, 3, $now, $now)`, + { $now: now }, + ); + + const ids = getDescendantIds("root"); + expect(ids).toEqual(["open-child", "root"]); + expect(ids).not.toContain("archived-child"); + expect(ids).not.toContain("orphan"); + }); + + it("handles a non-existent id gracefully", () => { + const ids = getDescendantIds("does-not-exist"); + expect(ids).toEqual(["does-not-exist"]); + }); + + it("defends against accidental parent_tab_id cycles", () => { + const now = Date.now(); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('x', 'X', 'y', 'idle', 1, 0, $now, $now)`, + { $now: now }, + ); + memDb.run( + `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at) + VALUES ('y', 'Y', 'x', 'idle', 1, 1, $now, $now)`, + { $now: now }, + ); + + // Must terminate — no infinite loop + const ids = getDescendantIds("x"); + expect(ids).toContain("x"); + expect(ids).toContain("y"); + expect(ids).toHaveLength(2); + }); + + it("uses createTab helper and asserts is_open flag", () => { + createTab("a1", "A1"); + createTab("b1", "B1", { parentTabId: "a1" }); + createTab("c1", "C1", { parentTabId: "b1" }); + + // All three should be open + expect(getTab("a1")?.isOpen).toBe(true); + expect(getTab("b1")?.isOpen).toBe(true); + expect(getTab("c1")?.isOpen).toBe(true); + + // getDescendantIds sees all three + const ids = getDescendantIds("a1"); + expect(ids).toEqual(["c1", "b1", "a1"]); + + // Archive the leaf, then it should disappear + archiveTab("c1"); + expect(getTab("c1")?.isOpen).toBe(false); + + const ids2 = getDescendantIds("a1"); + expect(ids2).toEqual(["b1", "a1"]); + }); +}); diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index dac7a3a..3bbc4ab 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -4,6 +4,8 @@ import { tabStore } from "../tabs.svelte.js"; let inputEl: HTMLInputElement | undefined; let inputValue = $state(""); +const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle"); + $effect(() => { inputEl?.focus(); }); @@ -24,6 +26,19 @@ function submit() {
+ {#if agentStatus === "running"} + + {:else if agentStatus === "idle"} + + + + {:else if agentStatus === "error"} + + + + + + {/if}