diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 09:42:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 09:42:55 +0900 |
| commit | 1e70f2d12274da833035912206b1ac0b1ee57ef1 (patch) | |
| tree | c87cb29a36d833228f2e1e16f94fcc920d8ca17e | |
| parent | 6662622e1c89fa1124b7342f136ab5f8d3c97972 (diff) | |
| download | dispatch-1e70f2d12274da833035912206b1ac0b1ee57ef1.tar.gz dispatch-1e70f2d12274da833035912206b1ac0b1ee57ef1.zip | |
feat: add agent status indicator in chat input, db tabs tests, and service management script
| -rwxr-xr-x | bin/service | 85 | ||||
| -rw-r--r-- | packages/core/tests/db/tabs.test.ts | 199 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 15 |
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} |
