summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 09:42:55 +0900
committerAdam Malczewski <[email protected]>2026-05-28 09:42:55 +0900
commit1e70f2d12274da833035912206b1ac0b1ee57ef1 (patch)
treec87cb29a36d833228f2e1e16f94fcc920d8ca17e
parent6662622e1c89fa1124b7342f136ab5f8d3c97972 (diff)
downloaddispatch-1e70f2d12274da833035912206b1ac0b1ee57ef1.tar.gz
dispatch-1e70f2d12274da833035912206b1ac0b1ee57ef1.zip
feat: add agent status indicator in chat input, db tabs tests, and service management script
-rwxr-xr-xbin/service85
-rw-r--r--packages/core/tests/db/tabs.test.ts199
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte15
3 files changed, 299 insertions, 0 deletions
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 <<EOF
+Usage: bin/service <command>
+
+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() {
</script>
<div class="flex items-center gap-2 p-3">
+ {#if agentStatus === "running"}
+ <span class="loading loading-spinner loading-sm text-primary"></span>
+ {:else if agentStatus === "idle"}
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-success">
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {:else if agentStatus === "error"}
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-error">
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="8" x2="12" y2="12"></line>
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
+ </svg>
+ {/if}
<input
bind:this={inputEl}
bind:value={inputValue}