feat: Svelte 5 command dashboard with real-time WebSocket updates#76
feat: Svelte 5 command dashboard with real-time WebSocket updates#76justinjdev wants to merge 44 commits intomainfrom
Conversation
📝 WalkthroughWalkthroughThis PR comprehensively replaces the dashboard frontend with a SvelteKit-based application and extends the backend with WebSocket communication, command queuing, error logging, configuration management, and multiple new API endpoints. The static HTML/JavaScript dashboard is replaced by a modular, component-driven architecture. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Browser Client
participant WS as WebSocket
participant Handler as HTTP Handler
participant Queue as Command Queue
participant DB as Database
participant Effect as Effect Processor
Client->>Handler: POST /api/spawn-quest<br/>(task, branch, company)
Handler->>Handler: Validate request body
Handler->>Handler: Marshal params to JSON
Handler->>Queue: EnqueueCommand(gitRoot,<br/>ActionSpawnQuest, params)
Queue->>Queue: Lock + Load queue
Queue->>Queue: Generate random ID
Queue->>Queue: Append command<br/>(status: pending)
Queue->>Queue: Prune to 200 items
Queue->>Queue: Save to .json file
Queue-->>Handler: Return Command
Handler->>WS: Broadcast WSEvent<br/>(type: "command-queued")
WS-->>Client: Send event
Handler-->>Client: 200 OK<br/>(Queued: true, CommandID)
Effect->>DB: Poll command queue
Effect->>Effect: Execute command
Effect->>DB: Update command status
Effect->>WS: Broadcast status change
WS-->>Client: Update in real-time
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 19
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (9)
cli/go.mod-5-5 (1)
5-5:⚠️ Potential issue | 🟡 MinorRemove
// indirectcomment fromgorilla/websocketdependency.The module is imported directly in
cli/internal/dashboard/ws.goat line 10, but marked as indirect ingo.modline 5. Remove the// indirectcomment and rungo mod tidyto correct the dependency metadata.cli/internal/dashboard/static/_app/immutable/chunks/DmxyyQfE.js-1-1 (1)
1-1:⚠️ Potential issue | 🟡 MinorAdd error handling to loading state in gate actions.
The
approve()andreject()functions lack error handling. If eitherapproveGate(worktree)orrejectGate(worktree)throws, theloadingstate is never reset tofalse, leaving buttons permanently disabled.Use a
try/finallyblock (consistent withCommandPalette.sveltein the same codebase):async function approve() { loading = true; try { await approveGate(worktree); } finally { loading = false; } }cli/internal/dashboard/ws.go-25-27 (1)
25-27:⚠️ Potential issue | 🟡 MinorMisleading comment:
CheckOriginallows all origins, not just localhost.The comment says "localhost only" but the implementation returns
trueunconditionally, accepting WebSocket connections from any origin. If this is intentional for development, consider adding a TODO or making it configurable. For production, validate the origin against expected hosts.🛡️ Proposed fix for localhost-only validation
var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, // localhost only + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + // Allow localhost origins for development + return origin == "" || + strings.HasPrefix(origin, "http://localhost") || + strings.HasPrefix(origin, "http://127.0.0.1") + }, }Add
"strings"to the imports if using this approach.cli/internal/autopsy/autopsy_test.go-417-423 (1)
417-423:⚠️ Potential issue | 🟡 MinorMake this assertion fail instead of only logging.
If
inferModulesregresses here, the test still passes because this branch only callst.Logf. That turns the first half ofTestInferModulesinto a no-op.Proposed fix
modules := inferModules([]string{"src/auth/jwt.go", "src/auth/session.go", "src/billing/charge.go"}) - if len(modules) != 1 { - // Both start with "src" - t.Logf("modules = %v", modules) - } + if len(modules) != 1 || modules[0] != "src" { + t.Fatalf("modules = %v, want [src]", modules) + }dashboard/src/lib/components/HeraldFeed.svelte-17-25 (1)
17-25:⚠️ Potential issue | 🟡 MinorHandle invalid timestamps gracefully.
If
tsis invalid or malformed,new Date(ts).getTime()returnsNaN, which propagates through the calculation and displays "NaNd". Consider adding a guard.🛡️ Proposed defensive fix
function formatTime(ts: string): string { - const diff = Date.now() - new Date(ts).getTime(); + const date = new Date(ts); + if (isNaN(date.getTime())) return '?'; + const diff = Date.now() - date.getTime(); const mins = Math.floor(diff / 60000);dashboard/src/routes/timeline/+page.svelte-17-18 (1)
17-18:⚠️ Potential issue | 🟡 MinorAdd error handling for
fetchTomefailures.If any
fetchTomecall rejects,Promise.allwill reject, leavingloadingastrueindefinitely. Consider wrapping individual fetches or the entire block in try/catch.🛡️ Proposed fix using Promise.allSettled
-const results = await Promise.all(names.map((n) => fetchTome(n))); -tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[]; +const results = await Promise.allSettled(names.map((n) => fetchTome(n))); +tomes = results + .filter((r): r is PromiseFulfilledResult<QuestTome | null> => r.status === 'fulfilled') + .map((r) => r.value) + .filter((t): t is QuestTome => t != null);dashboard/src/routes/autopsies/+page.svelte-38-41 (1)
38-41:⚠️ Potential issue | 🟡 MinorAdd error handling for fetch failure.
If
fetchAutopsies()throws, theloadingstate remainstrueand the user sees "Loading autopsies..." indefinitely with no feedback.🛡️ Proposed fix
+let error = $state<string | null>(null); + onMount(async () => { - autopsies = (await fetchAutopsies()) as Autopsy[]; - loading = false; + try { + autopsies = (await fetchAutopsies()) as Autopsy[]; + } catch (e) { + error = 'Failed to load autopsies'; + } finally { + loading = false; + } });dashboard/src/lib/components/CommandPalette.svelte-166-173 (1)
166-173:⚠️ Potential issue | 🟡 MinorGuard arrow-key math when there are no matches.
If
filteredis empty,items.lengthis0, so Lines 169 and 173 assignNaNtoselectedIndex. A simple empty-list guard keeps keyboard navigation stable.💡 Proposed fix
} else if (e.key === 'ArrowDown') { e.preventDefault(); const items = flatItems(); + if (!items.length) return; selectedIndex = (selectedIndex + 1) % items.length; } else if (e.key === 'ArrowUp') { e.preventDefault(); const items = flatItems(); + if (!items.length) return; selectedIndex = (selectedIndex - 1 + items.length) % items.length;dashboard/src/routes/herald/+page.svelte-33-45 (1)
33-45:⚠️ Potential issue | 🟡 MinorAll type filters off currently means “show everything.”
When every checkbox is unchecked,
anyTypeEnabledbecomes false and the type filter stops applying. That makes it impossible to hide all event types.💡 Proposed fix
if (selectedQuest && t.quest !== selectedQuest) return false; const anyTypeEnabled = Object.values(enabledTypes).some(Boolean); - if (anyTypeEnabled && !enabledTypes[t.type]) return false; + if (!anyTypeEnabled) return false; + if (!enabledTypes[t.type]) return false; if (searchText) {
🧹 Nitpick comments (22)
cli/internal/dashboard/static/_app/immutable/chunks/sifeoocM.js (1)
1-1: Committed build artifact — ensure reproducibility and consider documentation.This is minified SvelteKit compiler output. Committing build artifacts to version control can cause merge conflicts and bloat repository history, but is acceptable when embedding assets in a Go binary via
embed.FSas noted in the PR objectives.Recommendations:
- Add a
README.mdor comment incli/internal/dashboard/static/explaining that these files are generated and should not be manually edited.- Document the build command (e.g.,
npm run buildin the Svelte project) in the repo's contributing guide.- Consider adding a CI step that verifies the committed build matches a fresh build to catch drift.
cli/internal/dashboard/static/_app/version.json (1)
1-1: Build artifact committed as part of embedding strategy.This SvelteKit-generated version file uses a timestamp for cache-busting and is being committed as part of the
embed.FSstrategy described in the PR. The approach is valid and serves its purpose.If reproducible builds become a priority, consider using a git commit hash or semantic version instead of the build timestamp. This would make builds deterministic across different build times.
cli/internal/datadir/datadir_test.go (1)
264-280: Add coverage for non-positiveexpiryDaysoverrides.
AutopsyExpiryDayshas a dedicated fallback for<= 0, but these tests only cover the unset and positive paths. A bad config like0or-1can regress silently without its own assertion.Suggested test
+func TestAutopsyExpiryDays_NonPositiveFallsBackToDefault(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatalf("creating claude dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(home, ".claude", "fellowship.json"), + []byte(`{"autopsy":{"expiryDays":0}}`), + 0o644, + ); err != nil { + t.Fatalf("writing config: %v", err) + } + + if got := AutopsyExpiryDays(90); got != 90 { + t.Errorf("AutopsyExpiryDays = %d, want 90", got) + } +}cli/internal/datadir/datadir.go (1)
105-113: Consider aligning config merge strategy withName().The
AutopsyExpiryDaysfunction only reads from user config (~/.claude/fellowship.json), whileName()merges project config (.fellowship/config.json) with user config where user wins. If autopsy expiry should also be configurable per-project, consider applying the same merge pattern.If this is intentional (user-level setting only), the docstring is accurate and no change is needed.
♻️ Optional: Apply project+user merge pattern
func AutopsyExpiryDays(defaultDays int) int { - c := readUserConfig() - if c.Autopsy.ExpiryDays <= 0 { + days := 0 + if p := readProjectConfig(); p.Autopsy.ExpiryDays > 0 { + days = p.Autopsy.ExpiryDays + } + if u := readUserConfig(); u.Autopsy.ExpiryDays > 0 { + days = u.Autopsy.ExpiryDays + } + if days <= 0 { return defaultDays } - return c.Autopsy.ExpiryDays + return days }cli/internal/dashboard/static/index.html (1)
32-37: Consider adding error handling to the bootstrap Promise.If module loading fails (e.g., network error, corrupted bundle), the Promise rejection is unhandled and the app silently fails to start. Adding a
.catch()handler would provide user feedback on load failure.🛡️ Suggested improvement
Promise.all([ import("/_app/immutable/entry/start.2wbYeCGw.js"), import("/_app/immutable/entry/app.D9gBBLez.js") ]).then(([kit, app]) => { kit.start(app, element); + }).catch((err) => { + console.error('Failed to load dashboard:', err); + element.innerHTML = '<p style="color:red;padding:1rem;">Failed to load dashboard. Please refresh.</p>'; });cli/internal/dashboard/ws.go (1)
80-88: Read pump lacks read deadline for connection health detection.Without a read deadline, the read pump relies solely on TCP-level detection which can take minutes to notice a dead peer. Setting a read deadline with periodic pong handling ensures faster detection of unresponsive clients.
♻️ Proposed improvement with read deadline and pong handler
func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("ws: upgrade error: %v", err) return } h.Add(conn) + const ( + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + ) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) // Read pump — just drain pings/pongs, we don't expect client messages go func() { defer h.Remove(conn) for { if _, _, err := conn.ReadMessage(); err != nil { break } } }() }Note: This requires a separate ping ticker goroutine to send pings periodically if the server needs to initiate keepalive probes.
cli/internal/dashboard/ws_test.go (1)
8-11: Test lacks assertions and meaningful verification.This test broadcasts to an empty hub (no connections), which is a no-op. It doesn't verify that messages are actually received by connected clients, nor does it test connection add/remove behavior.
♻️ Proposed test with actual WebSocket connection
func TestHubBroadcast(t *testing.T) { hub := NewHub() - hub.Broadcast(WSEvent{Type: "test", Timestamp: time.Now().Unix()}) + + // Start test server + server := httptest.NewServer(http.HandlerFunc(hub.HandleWS)) + defer server.Close() + + // Connect WebSocket client + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + // Give connection time to register + time.Sleep(10 * time.Millisecond) + + // Broadcast event + event := WSEvent{Type: "test", Timestamp: time.Now().Unix()} + hub.Broadcast(event) + + // Verify receipt + conn.SetReadDeadline(time.Now().Add(time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read: %v", err) + } + + var received WSEvent + if err := json.Unmarshal(msg, &received); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if received.Type != "test" { + t.Errorf("got type %q, want %q", received.Type, "test") + } }Add imports:
"encoding/json","net/http","net/http/httptest","strings","github.com/gorilla/websocket".Would you like me to generate additional test cases for connection removal and concurrent broadcast scenarios?
dashboard/src/routes/+page.svelte (1)
1-7: Prefer a route redirect overonMountnavigation.With
ssr = false, this page stays empty until the client boots andonMountruns. Moving the redirect into+page.jsavoids rendering a throwaway page at all.♻️ Proposed change
-<script> - import { goto } from '$app/navigation'; - import { onMount } from 'svelte'; - - onMount(() => { - goto('/command', { replaceState: true }); - }); -</script> +// dashboard/src/routes/+page.js +import { redirect } from '@sveltejs/kit'; + +export function load() { + throw redirect(307, '/command'); +}dashboard/src/app.html (1)
6-8: Self-host these fonts instead of fetching them at runtime.This makes the embedded dashboard depend on outbound access to Google, which is a poor fit for a local/embedded admin UI and leaks requests from
localhostsessions. If this dashboard is expected to work in offline or locked-down environments, keep the font assets under the app’s static bundle instead.cli/internal/dashboard/queue_test.go (1)
26-32: Assert the persisted command payload, not just the count.Right now this still passes if
EnqueueCommandwrites the wrongActionorParamsas long as one row lands in the file. Since the queue is the integration point for Gandalf, it’s worth checking the full round-trip here.Suggested assertions
q, err := LoadCommandQueue(dir) if err != nil { t.Fatalf("LoadCommandQueue failed: %v", err) } if len(q.Commands) != 1 { t.Errorf("expected 1 command, got %d", len(q.Commands)) } + if q.Commands[0].ID != cmd.ID { + t.Errorf("expected persisted ID %q, got %q", cmd.ID, q.Commands[0].ID) + } + if q.Commands[0].Action != ActionSpawnQuest { + t.Errorf("expected action %q, got %q", ActionSpawnQuest, q.Commands[0].Action) + } + if string(q.Commands[0].Params) != string(params) { + t.Errorf("expected params %s, got %s", string(params), string(q.Commands[0].Params)) + }dashboard/src/lib/components/ConnectionBanner.svelte (1)
5-9: Consider adding accessibility attributes for screen reader users.The connection status banner would benefit from
role="alert"oraria-live="polite"to announce connectivity changes to screen reader users.♿ Proposed accessibility improvement
{`#if` !$connected} - <div class="banner"> + <div class="banner" role="alert" aria-live="polite"> Disconnected — reconnecting... </div> {/if}dashboard/src/routes/command/+page.svelte (1)
4-4: Unused import:refreshAll.The
refreshAllfunction is imported but never used in this component. Consider removing it to keep imports clean.🧹 Remove unused import
- import { dashboardStatus, questHealths, problems, activeQuests, pendingGates, activeScouts, refreshAll, startPolling, stopPolling } from '$lib/stores/quests'; + import { dashboardStatus, questHealths, problems, activeQuests, pendingGates, activeScouts, startPolling, stopPolling } from '$lib/stores/quests';dashboard/src/lib/components/HeraldFeed.svelte (1)
39-46: Add a key to the{#each}block for stable updates.Without a key, Svelte may inefficiently re-render items when the tidings list changes. If tidings have a unique identifier, use it as the key.
♻️ Add keyed iteration
-{`#each` tidings.slice(0, limit) as tiding} +{`#each` tidings.slice(0, limit) as tiding (tiding.timestamp + tiding.quest)}If tidings have a unique
idfield, prefer using that instead.dashboard/src/lib/components/Sidebar.svelte (2)
43-45: Add accessibility attributes to the toggle button.The toggle button lacks an accessible label for screen readers. Consider adding
aria-labeland optionallyaria-expandedon the sidebar.♿ Proposed accessibility improvement
-<button class="sidebar-toggle" onclick={() => collapsed = !collapsed}> +<button + class="sidebar-toggle" + onclick={() => collapsed = !collapsed} + aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} +> {collapsed ? '›' : '‹'} </button>
49-58: Consider using a CSS variable instead of hardcoded color.The sidebar background uses
#0a0c10directly. For consistency with the design system, consider using a CSS variable (e.g., define--bg-sidebarin tokens).🎨 Use design token
.sidebar { width: var(--sidebar-width-expanded); - background: `#0a0c10`; + background: var(--bg-sidebar, `#0a0c10`);Then define
--bg-sidebar:#0a0c10;in your tokens file.dashboard/src/routes/timeline/+page.svelte (2)
12-20: Consider debouncing or guarding against concurrentloadTomescalls.Every
dashboardStatusupdate triggersloadTomes(). If updates arrive rapidly, multiple concurrent fetches could race, potentially causing stale data to overwrite fresh data. Consider adding a guard:♻️ Proposed guard
+let loadingTomes = false; + async function loadTomes() { + if (loadingTomes) return; + loadingTomes = true; const status = $dashboardStatus; if (!status) return; const names = status.quests.map((q) => q.name); const results = await Promise.all(names.map((n) => fetchTome(n))); tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[]; loading = false; + loadingTomes = false; }
18-18: Redundant type assertion.The type guard
(t): t is QuestTome => t != nullalready narrows the type, makingas QuestTome[]unnecessary.-tomes = results.filter((t): t is QuestTome => t != null) as QuestTome[]; +tomes = results.filter((t): t is QuestTome => t != null);dashboard/src/routes/quest/[id]/+page.svelte (1)
30-31: Type assertions may mask null returns.If
fetchErrandsorfetchTomereturnnull, the assertionsas QuestErrandListandas QuestTomewill incorrectly type the null values. The template already handles null checks (errandList && ...), so removing the assertions maintains correctness:-errandList = e as QuestErrandList; -tomeData = t as QuestTome; +errandList = e; +tomeData = t;This requires updating the type of the state variables to include
nullexplicitly if not already.dashboard/src/lib/components/GanttChart.svelte (1)
115-125: Consider adding keyboard accessibility for bar segments.The
svelte-ignore a11y_no_static_element_interactionscomment suppresses the warning, but the bars lack keyboard focus. For accessibility, consider making bars focusable withtabindex="0"and handlingfocus/blurfor tooltip display:<div class="gantt-bar" + tabindex="0" + role="button" + aria-label="{seg.phase}: {seg.duration}" style:left="{seg.startPct}%" ... onmouseenter={(e) => onSegmentEnter(e, row.name, seg)} onmouseleave={onSegmentLeave} + onfocus={(e) => onSegmentEnter(e, row.name, seg)} + onblur={onSegmentLeave} ></div>dashboard/src/lib/stores/herald.ts (1)
16-28: MakestartHeraldPolling()idempotent.A second start overwrites
unsubscribewithout disposing the previous subscription, so each matching websocket event can fan out into duplicate/api/heraldfetches.💡 Proposed fix
export function startHeraldPolling() { + unsubscribe?.(); fetchTidings(); unsubscribe = lastEvent.subscribe((event) => { if (!event) return; if (event.type === 'herald-event' || event.type === 'gate-resolved' || event.type === 'quest-changed') { fetchTidings(); } }); } export function stopHeraldPolling() { unsubscribe?.(); + unsubscribe = null; }dashboard/src/lib/stores/quests.ts (1)
48-70: MakestartPolling()idempotent.A second start leaks the previous
lastEventsubscription becauseunsubscribeis overwritten without being called first. That can multiplyrefreshAll()calls for every websocket event.💡 Proposed fix
export function startPolling() { + unsubscribe?.(); refreshAll(); unsubscribe = lastEvent.subscribe((event) => { if (!event) return; switch (event.type) { @@ export function stopPolling() { unsubscribe?.(); + unsubscribe = null; }cli/internal/dashboard/commands.go (1)
27-45: Consider setting Content-Type header and handling the Encode error.The handler logic is correct - validation, enqueue, broadcast, respond. Two minor improvements:
- No
Content-Type: application/jsonheader is set before encoding the response.- The error from
json.NewEncoder(w).Encode()is discarded. While typically benign (client disconnect), logging could aid debugging.♻️ Suggested improvement
s.hub.Broadcast(WSEvent{Type: "command-queued", CommandID: cmd.ID}) + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID}) + _ = json.NewEncoder(w).Encode(QueuedResponse{Queued: true, CommandID: cmd.ID})The explicit
_ =documents the intentional discard. Alternatively, consider a helper function to reduce repetition across all handlers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5d937fa1-c12e-4131-8bfd-34f97e9effe1
⛔ Files ignored due to path filters (2)
cli/go.sumis excluded by!**/*.sumdashboard/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (98)
cli/cmd/fellowship/main.gocli/go.modcli/internal/autopsy/autopsy.gocli/internal/autopsy/autopsy_test.gocli/internal/dashboard/commands.gocli/internal/dashboard/data.gocli/internal/dashboard/queue.gocli/internal/dashboard/queue_test.gocli/internal/dashboard/server.gocli/internal/dashboard/static/_app/env.jscli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.csscli/internal/dashboard/static/_app/immutable/assets/3.BNs1lsiP.csscli/internal/dashboard/static/_app/immutable/assets/4.BQjEdRqL.csscli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.csscli/internal/dashboard/static/_app/immutable/assets/6.BoWh0gEj.csscli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.csscli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.csscli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.csscli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.csscli/internal/dashboard/static/_app/immutable/chunks/0bHCZF5Z.jscli/internal/dashboard/static/_app/immutable/chunks/Brf73Enf.jscli/internal/dashboard/static/_app/immutable/chunks/C6n-RmJK.jscli/internal/dashboard/static/_app/immutable/chunks/CX7xRtj3.jscli/internal/dashboard/static/_app/immutable/chunks/CfZZe3Sc.jscli/internal/dashboard/static/_app/immutable/chunks/CnZUc5Q9.jscli/internal/dashboard/static/_app/immutable/chunks/D36wQwZD.jscli/internal/dashboard/static/_app/immutable/chunks/D4lNzUeh.jscli/internal/dashboard/static/_app/immutable/chunks/D9Ae_xmU.jscli/internal/dashboard/static/_app/immutable/chunks/DDGGbvTe.jscli/internal/dashboard/static/_app/immutable/chunks/DH37wy21.jscli/internal/dashboard/static/_app/immutable/chunks/DQBMclug.jscli/internal/dashboard/static/_app/immutable/chunks/DTR50Ff_.jscli/internal/dashboard/static/_app/immutable/chunks/DWdIdLRT.jscli/internal/dashboard/static/_app/immutable/chunks/DfVq0qY5.jscli/internal/dashboard/static/_app/immutable/chunks/DmxyyQfE.jscli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.jscli/internal/dashboard/static/_app/immutable/chunks/gDmQICWX.jscli/internal/dashboard/static/_app/immutable/chunks/sifeoocM.jscli/internal/dashboard/static/_app/immutable/chunks/uxT17ePy.jscli/internal/dashboard/static/_app/immutable/entry/app.D9gBBLez.jscli/internal/dashboard/static/_app/immutable/entry/start.2wbYeCGw.jscli/internal/dashboard/static/_app/immutable/nodes/0.UJ1fGXlT.jscli/internal/dashboard/static/_app/immutable/nodes/1.CjKBPYHY.jscli/internal/dashboard/static/_app/immutable/nodes/2.fl-D6mRW.jscli/internal/dashboard/static/_app/immutable/nodes/3.B9fCFfLL.jscli/internal/dashboard/static/_app/immutable/nodes/4.Cn9Er3ny.jscli/internal/dashboard/static/_app/immutable/nodes/5.VELijUFU.jscli/internal/dashboard/static/_app/immutable/nodes/6.DInTcMZr.jscli/internal/dashboard/static/_app/immutable/nodes/7.BZGO45mE.jscli/internal/dashboard/static/_app/immutable/nodes/8.XpIxEabI.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/app.jscli/internal/dashboard/static/index.htmlcli/internal/dashboard/static/style.csscli/internal/dashboard/ws.gocli/internal/dashboard/ws_test.gocli/internal/datadir/datadir.gocli/internal/datadir/datadir_test.gocli/internal/hooks/guard.gocli/internal/hooks/guard_test.godashboard/.gitignoredashboard/build.shdashboard/package.jsondashboard/src/app.htmldashboard/src/lib/api.tsdashboard/src/lib/components/CommandPalette.sveltedashboard/src/lib/components/ConnectionBanner.sveltedashboard/src/lib/components/ErrandList.sveltedashboard/src/lib/components/GanttChart.sveltedashboard/src/lib/components/GateActions.sveltedashboard/src/lib/components/HeraldFeed.sveltedashboard/src/lib/components/PhaseTimeline.sveltedashboard/src/lib/components/QuestCard.sveltedashboard/src/lib/components/Shell.sveltedashboard/src/lib/components/Sidebar.sveltedashboard/src/lib/components/StatCounter.sveltedashboard/src/lib/stores/herald.tsdashboard/src/lib/stores/quests.tsdashboard/src/lib/stores/websocket.tsdashboard/src/lib/styles/global.cssdashboard/src/lib/styles/tokens.cssdashboard/src/lib/types.tsdashboard/src/routes/+layout.jsdashboard/src/routes/+layout.sveltedashboard/src/routes/+page.sveltedashboard/src/routes/autopsies/+page.sveltedashboard/src/routes/command/+page.sveltedashboard/src/routes/config/+page.sveltedashboard/src/routes/herald/+page.sveltedashboard/src/routes/quest/[id]/+page.sveltedashboard/src/routes/timeline/+page.sveltedashboard/svelte.config.jsdashboard/tsconfig.jsondashboard/vite.config.tsplugin/commands/rekindle.mdplugin/skills/fellowship/SKILL.mdplugin/skills/quest/SKILL.mdplugin/skills/retro/SKILL.md
💤 Files with no reviewable changes (2)
- cli/internal/dashboard/static/app.js
- cli/internal/dashboard/static/style.css
There was a problem hiding this comment.
Actionable comments posted: 4
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c3937b41-006d-45d2-9da5-68fa3bf65eeb
📒 Files selected for processing (2)
cli/internal/dashboard/embed.gocli/internal/dashboard/server.go
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (5)
cli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.js (1)
1-1: Review the source module, not this generated chunk.This is compiled/minified Svelte output, so any manual fix here will be overwritten on the next build. Please keep behavioral changes in the originating source file and treat this artifact as generated output to keep future reviews actionable.
cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css (1)
1-1: Verify the Herald feed can actually scroll inside the flex layout.
.feed-scrollis a flex child withoverflow-y:auto, but this rule set never gives the scroll region amin-height:0guard. In nested flex layouts, that can make the feed expand the page instead of becoming the scroll container. Please verify the Herald view in Firefox/Safari, and if the inner pane does not scroll correctly, addmin-height:0in the source stylesheet/component before rebuilding the asset.cli/internal/autopsy/autopsy.go (1)
155-158: Consider logging failed autopsy removals.Expired autopsy deletion failures are silently ignored. While not critical, this could lead to disk bloat if permissions or filesystem issues prevent cleanup.
🔧 Optional: log removal failures
if ts.Before(cutoff) { - os.Remove(path) + if err := os.Remove(path); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: failed to prune expired autopsy %s: %v\n", entry.Name(), err) + } continue }dashboard/src/lib/components/GateActions.svelte (1)
4-23: Consider adding error feedback to the user.When
approveGateorrejectGatethrows, the buttons re-enable but the user receives no indication that the operation failed. Consider adding an error state to display feedback.dashboard/src/routes/quest/[id]/+page.svelte (1)
31-34: Consider adding a loading indicator.While fetching errands and tome data, users see "No data available" rather than a loading state. A loading indicator would improve the UX during the async fetch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c16a5383-e385-4e83-95c2-299e61353fd1
📒 Files selected for processing (59)
cli/go.modcli/internal/autopsy/autopsy.gocli/internal/autopsy/autopsy_test.gocli/internal/dashboard/data.gocli/internal/dashboard/queue.gocli/internal/dashboard/server.gocli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.csscli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.csscli/internal/dashboard/static/_app/immutable/chunks/537vdszY.jscli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.jscli/internal/dashboard/static/_app/immutable/chunks/BTNO_X98.jscli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.jscli/internal/dashboard/static/_app/immutable/chunks/BVGfD6Eb.jscli/internal/dashboard/static/_app/immutable/chunks/Ba0bfBr4.jscli/internal/dashboard/static/_app/immutable/chunks/BjyTiIPo.jscli/internal/dashboard/static/_app/immutable/chunks/ByVGy8qs.jscli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.jscli/internal/dashboard/static/_app/immutable/chunks/CFIQ5hjj.jscli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.jscli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.jscli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.jscli/internal/dashboard/static/_app/immutable/chunks/CgqK3UK9.jscli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.jscli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.jscli/internal/dashboard/static/_app/immutable/chunks/DamZUyog.jscli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.jscli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.jscli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.jscli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.jscli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.jscli/internal/dashboard/static/_app/immutable/entry/app.BKAubqOz.jscli/internal/dashboard/static/_app/immutable/entry/start.4GtjckjD.jscli/internal/dashboard/static/_app/immutable/nodes/0.DX6K4gMq.jscli/internal/dashboard/static/_app/immutable/nodes/1.BNOz1B6M.jscli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.jscli/internal/dashboard/static/_app/immutable/nodes/3.Dy9zUKhR.jscli/internal/dashboard/static/_app/immutable/nodes/4.JGirkQFV.jscli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.jscli/internal/dashboard/static/_app/immutable/nodes/6.YDCVnqWQ.jscli/internal/dashboard/static/_app/immutable/nodes/7.CXeBpwE0.jscli/internal/dashboard/static/_app/immutable/nodes/8.D_pQ694c.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/index.htmlcli/internal/dashboard/ws.godashboard/src/lib/api.tsdashboard/src/lib/components/CommandPalette.sveltedashboard/src/lib/components/GateActions.sveltedashboard/src/lib/components/HeraldFeed.sveltedashboard/src/lib/components/QuestCard.sveltedashboard/src/lib/components/Shell.sveltedashboard/src/lib/stores/websocket.tsdashboard/src/routes/+page.jsdashboard/src/routes/+page.sveltedashboard/src/routes/autopsies/+page.sveltedashboard/src/routes/herald/+page.sveltedashboard/src/routes/quest/[id]/+page.sveltedashboard/src/routes/timeline/+page.svelteplugin/commands/rekindle.mdplugin/skills/fellowship/SKILL.md
✅ Files skipped from review due to trivial changes (1)
- cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
🚧 Files skipped from review as they are similar to previous changes (11)
- dashboard/src/lib/components/Shell.svelte
- dashboard/src/lib/stores/websocket.ts
- dashboard/src/routes/autopsies/+page.svelte
- dashboard/src/lib/components/QuestCard.svelte
- cli/go.mod
- dashboard/src/routes/timeline/+page.svelte
- cli/internal/dashboard/static/_app/version.json
- cli/internal/autopsy/autopsy_test.go
- cli/internal/dashboard/ws.go
- dashboard/src/routes/herald/+page.svelte
- plugin/commands/rekindle.md
| @@ -0,0 +1 @@ | |||
| import"./DsnmJJEf.js";import{p as m,d as n,g as i,s as g,t as x,l as s,b as h,_ as y,F as r}from"./CFIQ5hjj.js";import{d as _,s as c,b as f,a as k,f as j}from"./ByVGy8qs.js";import{b as w,r as G}from"./C67DyfnJ.js";var A=j('<div class="gate-actions svelte-1fnxfh2"><button class="gate-btn approve svelte-1fnxfh2"> </button> <button class="gate-btn reject svelte-1fnxfh2"> </button></div>');function B(p,o){m(o,!0);let t=y(!1);async function v(){r(t,!0);try{await w(o.worktree)}finally{r(t,!1)}}async function d(){r(t,!0);try{await G(o.worktree)}finally{r(t,!1)}}var l=A(),e=n(l),u=n(e,!0);i(e);var a=g(e,2),b=n(a,!0);i(a),i(l),x(()=>{e.disabled=s(t),c(u,s(t)?"...":"Approve"),a.disabled=s(t),c(b,s(t)?"...":"Reject")}),f("click",e,v),f("click",a,d),k(p,l),h()}_(["click"]);export{B as G}; | |||
There was a problem hiding this comment.
Add a synchronous guard to prevent duplicate approve/reject submissions.
These handlers only depend on the reactive disabled update. A quick double-activation can still invoke w(o.worktree) / G(o.worktree) twice before the buttons become disabled, which is risky for gate mutations. Please add an early if (loading) return; guard in the source component before setting the loading state.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{p as U,E as V,$ as X,a1 as Y,t as $,l as t,b as Z,d as l,s as i,F as P,_ as S,g as a,a0 as k}from"../chunks/CFIQ5hjj.js";import{a as u,f as _,s as T}from"../chunks/ByVGy8qs.js";import{s as ee,a as te}from"../chunks/DLS4WeON.js";import{i as se}from"../chunks/537vdszY.js";import{e as E,i as G}from"../chunks/zKsXIgWY.js";import{r as L}from"../chunks/DdAmTr_Q.js";import{b as ae,a as re}from"../chunks/jKnW4BRh.js";import{b as le}from"../chunks/CaohEQ9u.js";import{s as ie,a as oe,b as pe,c as ne}from"../chunks/BjyTiIPo.js";import{s as ve,a as de,t as ce,H as ue}from"../chunks/BTNO_X98.js";var _e=_('<option class="svelte-11xttp7"> </option>'),fe=_('<label class="type-checkbox svelte-11xttp7"><input type="checkbox" class="svelte-11xttp7"/> <span class="type-label svelte-11xttp7"> </span></label>'),me=_('<div class="empty-state svelte-11xttp7"><p>No tidings match the current filters.</p></div>'),ge=_('<div class="herald-view svelte-11xttp7"><div class="view-header svelte-11xttp7"><h1 class="svelte-11xttp7">Herald</h1> <span class="tiding-count svelte-11xttp7"> </span></div> <div class="filters svelte-11xttp7"><div class="filter-row svelte-11xttp7"><input type="text" class="search-input svelte-11xttp7" placeholder="Search tidings..."/> <select class="quest-select svelte-11xttp7"><option class="svelte-11xttp7">All Quests</option><!></select></div> <div class="type-filters svelte-11xttp7"></div></div> <div class="feed-scroll svelte-11xttp7"><!></div></div>');function Pe(A,B){U(B,!0);const C=()=>te(ce,"$tidings",F),[F,N]=ee();let n=S(""),v=S(""),d=V({gate_submitted:!0,gate_approved:!0,gate_rejected:!0,phase_transition:!0});const Q=["gate_submitted","gate_approved","gate_rejected","phase_transition"],W={gate_submitted:"Gate Submitted",gate_approved:"Gate Approved",gate_rejected:"Gate Rejected",phase_transition:"Phase Transition"};let q=k(C),D=k(()=>[...new Set(t(q).map(e=>e.quest))].filter(Boolean).sort()),c=k(()=>t(q).filter(e=>{if(t(v)&&e.quest!==t(v)||Object.values(d).some(Boolean)&&!d[e.type])return!1;if(t(n)){const s=t(n).toLowerCase();if(!`${e.quest} ${e.type} ${e.phase} ${e.detail}`.toLowerCase().includes(s))return!1}return!0}));X(()=>{ie(),oe(),ve()}),Y(()=>{pe(),ne(),de()});var f=ge(),m=l(f),w=i(l(m),2),M=l(w);a(w),a(m);var g=i(m,2),h=l(g),x=l(h);L(x);var b=i(x,2),y=l(b);y.value=y.__value="";var O=i(y);E(O,17,()=>t(D),G,(e,r)=>{var s=_e(),o=l(s,!0);a(s);var p={};$(()=>{T(o,t(r)),p!==(p=t(r))&&(s.value=(s.__value=t(r))??"")}),u(e,s)}),a(b),a(h);var j=i(h,2);E(j,21,()=>Q,G,(e,r)=>{var s=fe(),o=l(s);L(o);var p=i(o,2),J=l(p,!0);a(p),a(s),$(()=>T(J,W[t(r)])),re(o,()=>d[t(r)],K=>d[t(r)]=K),u(e,s)}),a(j),a(g);var H=i(g,2),R=l(H);{var z=e=>{ue(e,{get tidings(){return t(c)},get limit(){return t(c).length}})},I=e=>{var r=me();u(e,r)};se(R,e=>{t(c).length>0?e(z):e(I,-1)})}a(H),a(f),$(()=>T(M,`${t(c).length??""} tidings`)),ae(x,()=>t(n),e=>P(n,e)),le(b,()=>t(v),e=>P(v,e)),u(A,f),Z(),N()}export{Pe as component}; | |||
There was a problem hiding this comment.
Don't hardcode a partial event-type allowlist here.
d/Q only cover four tiding types, but cli/internal/dashboard/static/_app/immutable/chunks/BTNO_X98.js defines additional ones (lembas_completed, metadata_updated, quest_held, quest_unheld). Because the filter does !d[e.type], those events are hidden even before the user changes any filters. Please derive this state from the shared event-type map instead of a local subset.
Label the Herald filter controls.
The search <input> and quest <select> are rendered without an associated <label> or aria-label, so assistive tech will announce them as unlabeled controls. Please add accessible names in the source route component.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{p as G,f as Y,d as u,g as v,s as w,l as r,t as M,b as N,a0 as z,_ as C,F as j,E as A,$ as B,a1 as H}from"../chunks/CFIQ5hjj.js";import{s as J,a as K}from"../chunks/DLS4WeON.js";import{e as E,a as p,s as k,f}from"../chunks/ByVGy8qs.js";import{i as X}from"../chunks/537vdszY.js";import{d as L,s as Q,a as U,e as O,b as V,c as Z}from"../chunks/BjyTiIPo.js";import{d as tt}from"../chunks/C67DyfnJ.js";import{e as W,i as F}from"../chunks/zKsXIgWY.js";var et=f('<div class="gantt-bar svelte-t05lje"></div>'),at=f('<div class="gantt-row svelte-t05lje"><div class="gantt-label svelte-t05lje"> </div> <div class="gantt-track svelte-t05lje"></div></div>'),st=f('<div class="gantt-tooltip svelte-t05lje"><span class="tooltip-phase svelte-t05lje"> </span> <span class="tooltip-dur svelte-t05lje"> </span></div>'),rt=f('<div class="gantt-chart svelte-t05lje"></div> <!>',1);function nt(q,h){G(h,!0);const y={Onboard:"var(--accent-blue-dim)",Research:"var(--accent-purple-dim)",Plan:"var(--accent-gold-dim)",Implement:"var(--accent-green-dim)",Review:"var(--accent-gold)",Complete:"var(--accent-green)"},T={Onboard:"var(--accent-blue)",Research:"var(--accent-purple)",Plan:"var(--accent-gold)",Implement:"var(--accent-green-text)",Review:"var(--accent-gold)",Complete:"var(--accent-green-text)"};function S(s){const a=Math.floor(s/6e4);if(a<60)return`${a}m`;const t=Math.floor(a/60);return t<24?`${t}h ${a%60}m`:`${Math.floor(t/24)}d ${t%24}h`}let b=z(()=>{if(h.quests.length===0)return[];let s=1/0,a=-1/0;for(const l of h.quests){for(const d of l.phases_completed){const i=new Date(d.timestamp).getTime();i<s&&(s=i),i>a&&(a=i)}const e=new Date(l.created_at).getTime();e<s&&(s=e)}a<=s&&(a=s+1);const t=a-s,n=[];for(const l of h.quests){const e=[...l.phases_completed].sort((o,m)=>new Date(o.timestamp).getTime()-new Date(m.timestamp).getTime());if(e.length===0)continue;const d=[],i=new Date(l.created_at).getTime();for(let o=0;o<e.length;o++){const m=o===0?i:new Date(e[o-1].timestamp).getTime(),R=new Date(e[o].timestamp).getTime()-m;d.push({phase:e[o].phase,startPct:(m-s)/t*100,widthPct:Math.max(R/t*100,.5),duration:S(R)})}n.push({name:l.quest_name,segments:d})}return n}),c=C(null);function D(s,a,t){j(c,{quest:a,phase:t.phase,duration:t.duration,x:s.clientX,y:s.clientY},!0)}function x(){j(c,null)}var g=rt(),_=Y(g);W(_,21,()=>r(b),F,(s,a)=>{var t=at(),n=u(t),l=u(n,!0);v(n);var e=w(n,2);W(e,21,()=>r(a).segments,F,(d,i)=>{var o=et();let m;M(()=>m=L(o,"",m,{left:`${r(i).startPct??""}%`,width:`${r(i).widthPct??""}%`,background:y[r(i).phase]??"var(--bg-raised)",color:T[r(i).phase]??"var(--text-muted)"})),E("mouseenter",o,I=>D(I,r(a).name,r(i))),E("mouseleave",o,x),p(d,o)}),v(e),v(t),M(()=>k(l,r(a).name)),p(s,t)}),v(_);var P=w(_,2);{var $=s=>{var a=st();let t;var n=u(a),l=u(n,!0);v(n);var e=w(n,2),d=u(e,!0);v(e),v(a),M(()=>{t=L(a,"",t,{left:`${r(c).x+12}px`,top:`${r(c).y-8}px`}),k(l,r(c).phase),k(d,r(c).duration)}),p(s,a)};X(P,s=>{r(c)&&s($)})}p(q,g),N()}var ot=f('<div class="empty-state svelte-1fjec68">Loading quest timeline data...</div>'),lt=f('<div class="empty-state svelte-1fjec68">No quest timeline data available.</div>'),it=f('<div class="timeline-view svelte-1fjec68"><div class="view-header svelte-1fjec68"><h1 class="svelte-1fjec68">Timeline</h1></div> <div class="chart-area svelte-1fjec68"><!></div></div>');function gt(q,h){G(h,!0);const y=()=>K(O,"$dashboardStatus",T),[T,S]=J();let b=C(A([])),c=C(!0);async function D(){const t=y();if(!t)return;const n=t.quests.map(e=>e.name),l=await Promise.allSettled(n.map(e=>tt(e)));j(b,l.filter(e=>e.status==="fulfilled").map(e=>e.value).filter(e=>e!=null),!0),j(c,!1)}let x=null;B(()=>{Q(),U(),x=O.subscribe(t=>{t&&D()})}),H(()=>{V(),Z(),x?.()});var g=it(),_=w(u(g),2),P=u(_);{var $=t=>{var n=ot();p(t,n)},s=t=>{var n=lt();p(t,n)},a=t=>{nt(t,{get quests(){return r(b)}})};X(P,t=>{r(c)?t($):r(b).length===0?t(s,1):t(a,-1)})}v(_),v(g),p(q,g),N(),S()}export{gt as component}; | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if any NaN guards exist in the dashboard source files
rg -n "isNaN|Number\.isNaN|isFinite" --type ts --type svelte 2>/dev/null || \
rg -n "isNaN|Number\.isNaN|isFinite" -g "*.svelte" -g "*.ts"
# Look for the timeline source component to verify NaN handling
fd -i timeline -e svelte --type f --exec cat {}Repository: justinjdev/fellowship
Length of output: 1129
🏁 Script executed:
# Find the timeline/gantt chart source component
fd -i gantt -e svelte --type f
fd -i timeline -e ts --type f
fd -i timeline -e svelte --type f
# Look for the dashboard source files
find . -path "*/dashboard/src*" -type f \( -name "*.svelte" -o -name "*.ts" \) | head -20Repository: justinjdev/fellowship
Length of output: 1040
🏁 Script executed:
cat -n dashboard/src/lib/components/GanttChart.svelteRepository: justinjdev/fellowship
Length of output: 6589
🏁 Script executed:
cat -n dashboard/src/lib/types.ts | head -100
cat -n dashboard/src/lib/api.ts | head -100Repository: justinjdev/fellowship
Length of output: 6388
Add NaN validation for Gantt chart percentage calculations.
When timestamps in phases_completed are invalid date strings, new Date().getTime() returns NaN, which propagates through the timeline calculations. Even with the globalMax <= globalMin guard (line 60), if all timestamps are invalid, range becomes NaN, producing invalid CSS values like left: NaN% and width: NaN%. While the API layer should ideally validate timestamps, adding a check like isNaN(range) before rendering segments would prevent silent rendering failures:
if (isNaN(range) || range <= 0) {
// Skip rendering or return empty
return [];
}| let filtered = $derived( | ||
| query | ||
| ? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase())) | ||
| : actions | ||
| ); |
There was a problem hiding this comment.
Use a real fuzzy matcher here.
This is still contiguous substring matching, so queries like sq / rq will never find Spawn Quest / Restart Quest. That misses the fuzzy-search behavior this PR claims to add.
Suggested direction
+ function fuzzyMatch(query: string, label: string): boolean {
+ const needle = query.toLowerCase().replace(/\s+/g, '');
+ const haystack = label.toLowerCase();
+ let i = 0;
+ for (const ch of haystack) {
+ if (ch === needle[i]) i++;
+ if (i === needle.length) return true;
+ }
+ return needle.length === 0;
+ }
+
let filtered = $derived(
- query
- ? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase()))
- : actions
+ query ? actions.filter((a) => fuzzyMatch(query, a.label)) : actions
);There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (1)
cli/internal/dashboard/queue.go (1)
83-87:⚠️ Potential issue | 🟡 MinorConsider adding file-level synchronization for queue writes to prevent multi-process races.
The mutex currently protects only in-process concurrency. While the dashboard runs as a single server instance today, if additional CLI processes or multiple dashboard instances ever access the queue file simultaneously, the predictable
.tmpfilename creates a race condition: both processes could write to the same temporary file, and rename ordering could lose data.Since the codebase already uses
filelockelsewhere (e.g.,state.go,fellowship.go), consider either:
- Adding filelock around queue reads/writes:
+ lockFile := path + ".lock" + f, _ := os.Create(lockFile) + filelock.Lock(f.Fd()) + defer filelock.Unlock(f.Fd()) // existing write logic
- Or use a unique tmp name to avoid collisions:
- tmp := path + ".tmp" + tmp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())This is defensive programming—currently only the single dashboard instance writes to the queue, but explicit locking prevents issues if the architecture changes.
🧹 Nitpick comments (8)
cli/internal/autopsy/autopsy.go (3)
157-162: Consider documenting the pruning side effect in the function signature.The
Scanfunction performs filesystem writes (deleting expired autopsies) during what appears to be a read operation. While this lazy cleanup is efficient, callers may not expect a "scan" to mutate state. Consider adding a comment to the function doc or renaming toScanAndPrune.
264-278: Path separator handling may be inconsistent across platforms.
inferModules(line 247) normalizes paths withfilepath.ToSlash, butmatchesFiltersusesfilepath.Dirwhich is OS-dependent. If autopsy files are stored with forward slashes,filepath.Diron Windows will not correctly extract directory components.♻️ Proposed fix for consistent path handling
func matchesFilters(a *Autopsy, opts ScanOptions) bool { // File path prefix match for _, queryFile := range opts.Files { for _, autopsyFile := range a.Files { - if strings.HasPrefix(autopsyFile, queryFile) || strings.HasPrefix(queryFile, autopsyFile) { + normalizedQuery := filepath.ToSlash(queryFile) + normalizedAutopsy := filepath.ToSlash(autopsyFile) + if strings.HasPrefix(normalizedAutopsy, normalizedQuery) || strings.HasPrefix(normalizedQuery, normalizedAutopsy) { return true } // Also match if they share a directory prefix (skip root-level files) - queryDir := filepath.Dir(queryFile) - aDir := filepath.Dir(autopsyFile) + queryDir := filepath.Dir(normalizedQuery) + aDir := filepath.Dir(normalizedAutopsy) if queryDir != "." && aDir != "." && queryDir == aDir { return true }
313-320: Consider sanitizing additional characters for cross-platform filename safety.The
sanitizefunction is used to create filenames (line 99:sanitize(input.Quest)becomes part of the.jsonfilename). While the current quest names in the codebase don't contain problematic characters, Windows filenames prohibit:,*,?,",<,>, and|. Adding defensive sanitization for these characters would improve robustness across platforms.cli/internal/dashboard/data.go (1)
53-57: MissingContent-Typeheader for list response.The single-autopsy response sets
Content-Type: application/json(line 28), but the list response does not. While most clients will handle this, it's inconsistent and could cause issues with strict content-type checking.♻️ Proposed fix
sort.Slice(records, func(i, j int) bool { return records[i].Timestamp > records[j].Timestamp }) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(records) }dashboard/src/routes/quest/[id]/+page.svelte (3)
5-5: Unused import:PhaseTimeline.
PhaseTimelineis imported but never used in the template. Remove it to avoid dead code.- import PhaseTimeline from '$lib/components/PhaseTimeline.svelte';
82-93: Missing ARIA attributes for tab accessibility.The tabs lack
role,aria-selected, andaria-controlsattributes needed for screen reader users to understand the tab interface.♿ Suggested accessibility improvement
-<div class="tabs"> - <button class="tab" class:active={activeTab === 'errands'} onclick={() => activeTab = 'errands'}> +<div class="tabs" role="tablist"> + <button class="tab" class:active={activeTab === 'errands'} onclick={() => activeTab = 'errands'} + role="tab" aria-selected={activeTab === 'errands'} aria-controls="panel-errands"> Errands ... </button> - <button class="tab" class:active={activeTab === 'files'} onclick={() => activeTab = 'files'}>Files</button> + <button class="tab" class:active={activeTab === 'files'} onclick={() => activeTab = 'files'} + role="tab" aria-selected={activeTab === 'files'} aria-controls="panel-files">Files</button> <!-- Similar for other tabs -->And add
role="tabpanel"with matchingidattributes to content sections.
97-124: Consider adding a loading state and guarding against missing nested properties.
- The "No data available" fallback shows during initial load before data arrives, which may confuse users.
tomeData.files_touchedandtomeData.gate_historycould beundefinedif the API response shape differs, causing silent failures in{#each}.🛠️ Suggested improvements
+{`#if` !dataLoaded && quest} + <p class="empty">Loading...</p> +{:else if activeTab === 'errands' && errandList} -{`#if` activeTab === 'errands' && errandList} <ErrandList errands={errandList.items} /> {:else if activeTab === 'files' && tomeData} <div class="files-list"> - {`#each` tomeData.files_touched as file} + {`#each` tomeData.files_touched ?? [] as file} <div class="file-item">{file}</div> {/each} </div> {:else if activeTab === 'tome' && tomeData} <div class="tome-content"> <h3>Gate History</h3> - {`#each` tomeData.gate_history as gate} + {`#each` tomeData.gate_history ?? [] as gate}cli/internal/dashboard/queue.go (1)
42-44: Consider adding a pruning mechanism for completed/failed commands.The queue will grow unbounded over time since there's no removal of completed or failed commands. For long-running systems, consider:
- Pruning old completed/failed commands during
EnqueueCommand(e.g., keep only last N or commands newer than X days).- Adding a separate
PruneQueuefunction called periodically.This prevents the queue file from growing indefinitely and keeps load/save operations fast.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9e4fce6d-faac-4aa9-b553-3e3ebbd192bf
📒 Files selected for processing (5)
cli/internal/autopsy/autopsy.gocli/internal/dashboard/data.gocli/internal/dashboard/queue.godashboard/src/routes/config/+page.sveltedashboard/src/routes/quest/[id]/+page.svelte
🚧 Files skipped from review as they are similar to previous changes (1)
- dashboard/src/routes/config/+page.svelte
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (3)
cli/internal/dashboard/static/_app/immutable/nodes/3.DZm9JPwg.js (1)
1-1: This is compiled/minified build output — review the Svelte source files instead.This file is SvelteKit's bundled output (committed to enable Go's
embed.FS). Standard code review isn't applicable to minified artifacts. Please ensure the original.sveltesource files (likely under asrc/orfrontend/directory) are included in the review scope for meaningful feedback on logic, error handling, and UI behavior.If the source files aren't part of this PR, consider adding them or confirming they were reviewed separately.
cli/internal/dashboard/static/index.html (1)
34-39: Handle bootstrap import failures explicitly.If Line 35 or Line 36 fails to load, the page stays blank with no user-visible error. Adding a small
.catch(...)here would make stale asset/version mismatches much easier to diagnose.Suggested change
Promise.all([ import("/_app/immutable/entry/start.LZjJK6rN.js"), import("/_app/immutable/entry/app.B4sUXTrz.js") ]).then(([kit, app]) => { kit.start(app, element); + }).catch((error) => { + console.error("Failed to bootstrap dashboard", error); + element.innerHTML = + '<div role="alert">Failed to load dashboard. Refresh the page or restart the server.</div>'; });cli/internal/dashboard/static/_app/immutable/entry/app.B4sUXTrz.js (1)
1-2: Keep the emitted bundle reviewable.This hashed, minified asset turns small dashboard changes into opaque one-line diffs. If committed assets are required for
embed.FS, consider pointing reviewers to the originating SvelteKit source files or generating this output in CI so behavior changes stay auditable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 61569ed9-268e-4e8f-b643-f45778414672
📒 Files selected for processing (16)
cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.csscli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.jscli/internal/dashboard/static/_app/immutable/chunks/OlN70tzR.jscli/internal/dashboard/static/_app/immutable/chunks/loYStGme.jscli/internal/dashboard/static/_app/immutable/entry/app.B4sUXTrz.jscli/internal/dashboard/static/_app/immutable/entry/start.LZjJK6rN.jscli/internal/dashboard/static/_app/immutable/nodes/0.DGxOyW2-.jscli/internal/dashboard/static/_app/immutable/nodes/1.CTb2d7ZU.jscli/internal/dashboard/static/_app/immutable/nodes/3.DZm9JPwg.jscli/internal/dashboard/static/_app/immutable/nodes/4.DEgfp_0c.jscli/internal/dashboard/static/_app/immutable/nodes/5.VZsWOulv.jscli/internal/dashboard/static/_app/immutable/nodes/7.R1TVsdkV.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/index.htmldashboard/src/lib/components/GateActions.sveltedashboard/src/routes/autopsies/+page.svelte
✅ Files skipped from review due to trivial changes (1)
- cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
🚧 Files skipped from review as they are similar to previous changes (3)
- dashboard/src/lib/components/GateActions.svelte
- dashboard/src/routes/autopsies/+page.svelte
- cli/internal/dashboard/static/_app/version.json
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{a4 as ue,az as ye,a3 as we,b6 as be,aS as ke,w as B,ae as Ce,ay as ae,ac as se,a6 as le,ad as Ee,a7 as xe,p as Y,t as T,b as Z,d as p,s as w,g as v,l as e,i as oe,F as r,$ as ve,_ as q,f as O,a0 as U,a1 as Se,e as De,b7 as qe}from"../chunks/CFIQ5hjj.js";import{d as de,b as M,a as m,f as _,s as I,e as Qe,c as Pe}from"../chunks/ByVGy8qs.js";import{B as Fe,i as R}from"../chunks/537vdszY.js";import{e as V,i as W}from"../chunks/zKsXIgWY.js";import{a as J,r as ne}from"../chunks/DdAmTr_Q.js";import{s as X}from"../chunks/CQvy7xpd.js";import{p as Ne}from"../chunks/BVGfD6Eb.js";import{p as re}from"../chunks/OlN70tzR.js";import{b as ie}from"../chunks/jKnW4BRh.js";import{b as ce}from"../chunks/CSXl7R3t.js";import{g as K}from"../chunks/loYStGme.js";import{e as Te,g as Ie,k as Ae,h as Ke}from"../chunks/C67DyfnJ.js";function pe(h,o,...l){var u=new Fe(h);ue(()=>{const s=o()??null;u.ensure(s,s&&(t=>s(t,...l)))},ye)}function Me(h,o){let l=null,u=B;var s;if(B){l=Ee;for(var t=xe(document.head);t!==null&&(t.nodeType!==Ce||t.data!==h);)t=ae(t);if(t===null)se(!1);else{var i=ae(t);t.remove(),le(i)}}B||(s=document.head.appendChild(we()));try{ue(()=>o(s),be|ke)}finally{u&&(se(!0),le(l))}}const Oe=!1,Re=!1,pt=Object.freeze(Object.defineProperty({__proto__:null,prerender:Oe,ssr:Re},Symbol.toStringTag,{value:"Module"}));var Ge=_('<span class="logo-text svelte-129hoe0">Fellowship</span>'),Le=_('<span class="nav-label"> </span>'),He=_('<a><span class="nav-icon svelte-129hoe0"> </span> <!></a>'),je=_('<aside><div class="sidebar-header svelte-129hoe0"><div class="logo-mark svelte-129hoe0">F</div> <!></div> <nav class="sidebar-nav svelte-129hoe0"></nav> <button class="sidebar-toggle svelte-129hoe0"> </button></aside>');function ze(h,o){Y(o,!0);let l=Ne(o,"collapsed",15,!1);const u=[{path:"/command",label:"Command",icon:"⚔"},{path:"/herald",label:"Herald",icon:"📜"},{path:"/autopsies",label:"Autopsies",icon:"◆"},{path:"/timeline",label:"Timeline",icon:"▰"},{path:"/config",label:"Config",icon:"⚙"}];function s(y){return re.url.pathname===y||re.url.pathname.startsWith(y+"/")}var t=je();let i;var g=p(t),b=w(p(g),2);{var S=y=>{var C=Ge();m(y,C)};R(b,y=>{l()||y(S)})}v(g);var Q=w(g,2);V(Q,21,()=>u,W,(y,C)=>{var f=He();let F;var A=p(f),L=p(A,!0);v(A);var H=w(A,2);{var a=n=>{var c=Le(),D=p(c,!0);v(c),T(()=>I(D,e(C).label)),m(n,c)};R(H,n=>{l()||n(a)})}v(f),T(n=>{J(f,"href",e(C).path),F=X(f,1,"nav-item svelte-129hoe0",null,F,n),J(f,"title",l()?e(C).label:void 0),I(L,e(C).icon)},[()=>({active:s(e(C).path)})]),m(y,f)}),v(Q);var k=w(Q,2),d=p(k,!0);v(k),v(t),T(()=>{i=X(t,1,"sidebar svelte-129hoe0",null,i,{collapsed:l()}),I(d,l()?"›":"‹")}),M("click",k,()=>l(!l())),m(h,t),Z()}de(["click"]);var Be=_('<div class="input-mode-header svelte-wh9uu8"> </div> <input class="search-input svelte-wh9uu8" type="text"/>',1),Ue=_("<button> </button>"),Ve=_('<div class="category-header svelte-wh9uu8"> </div> <!>',1),We=_('<div class="no-results svelte-wh9uu8">No matching commands</div>'),Je=_('<input class="search-input svelte-wh9uu8" placeholder="Search commands..." type="text"/> <div class="action-list svelte-wh9uu8"><!> <!></div>',1),Xe=_('<div class="overlay svelte-wh9uu8"><div class="panel svelte-wh9uu8"><!></div></div>');function Ye(h,o){Y(o,!0);const l=[{label:"Spawn Quest",category:"Quest Control",action:()=>{r(t,"spawn-quest")},inputPlaceholder:"Enter task description..."},{label:"Spawn Scout",category:"Quest Control",action:()=>{r(t,"spawn-scout")},inputPlaceholder:"Enter question..."},{label:"Kill Quest",category:"Quest Control",action:()=>{r(t,"kill-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Restart Quest",category:"Quest Control",action:()=>{r(t,"restart-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Go to Command",category:"Navigation",action:()=>{K("/command"),o.onClose()}},{label:"Go to Herald",category:"Navigation",action:()=>{K("/herald"),o.onClose()}},{label:"Go to Autopsies",category:"Navigation",action:()=>{K("/autopsies"),o.onClose()}},{label:"Go to Timeline",category:"Navigation",action:()=>{K("/timeline"),o.onClose()}},{label:"Go to Config",category:"Navigation",action:()=>{K("/config"),o.onClose()}}];let u=q(""),s=q(0),t=q(null),i=q(""),g=q(void 0),b=q(void 0),S=U(()=>e(u)?l.filter(a=>a.label.toLowerCase().includes(e(u).toLowerCase())):l),Q=U(()=>()=>{const a=[],n=new Set;for(const c of e(S))n.has(c.category)||(n.add(c.category),a.push({category:c.category,items:[]})),a.find(D=>D.category===c.category).items.push(c);return a});function k(){return e(S)}function d(){const a=k();a[e(s)]&&a[e(s)].action()}async function y(){if(e(i).trim())try{e(t)==="spawn-quest"?await Te(e(i).trim()):e(t)==="spawn-scout"?await Ie(e(i).trim()):e(t)==="kill-quest"?await Ae(e(i).trim()):e(t)==="restart-quest"&&await Ke(e(i).trim()),r(t,null),r(i,""),o.onClose()}catch{}}function C(a){if(e(t)){a.key==="Escape"?(a.preventDefault(),r(t,null),r(i,""),e(g)?.focus()):a.key==="Enter"&&(a.preventDefault(),y());return}if(a.key==="Escape")a.preventDefault(),o.onClose();else if(a.key==="ArrowDown"){a.preventDefault();const n=k();r(s,(e(s)+1)%n.length)}else if(a.key==="ArrowUp"){a.preventDefault();const n=k();r(s,(e(s)-1+n.length)%n.length)}else a.key==="Enter"&&(a.preventDefault(),d())}oe(()=>{e(u),r(s,0)}),oe(()=>{e(t)&&e(b)&&e(b).focus()}),ve(()=>{e(g)?.focus()});var f=Xe(),F=p(f),A=p(F);{var L=a=>{var n=Be(),c=O(n),D=p(c,!0);v(c);var P=w(c,2);ne(P),ce(P,E=>r(b,E),()=>e(b)),T(E=>{I(D,e(t)==="spawn-quest"?"Spawn Quest":e(t)==="spawn-scout"?"Spawn Scout":e(t)==="kill-quest"?"Kill Quest":"Restart Quest"),J(P,"placeholder",E)},[()=>l.find(E=>E.label.toLowerCase().replace(/\s+/g,"-")===e(t)?.replace("spawn-","spawn-").replace("kill-","kill-").replace("restart-","restart-"))?.inputPlaceholder??"Enter value..."]),ie(P,()=>e(i),E=>r(i,E)),m(a,n)},H=a=>{var n=Je(),c=O(n);ne(c),ce(c,x=>r(g,x),()=>e(g));var D=w(c,2),P=p(D);V(P,17,()=>e(Q)(),W,(x,G)=>{var $=Ve(),j=O($),me=p(j,!0);v(j);var he=w(j,2);V(he,17,()=>e(G).items,W,(ge,z)=>{const ee=U(()=>e(S).indexOf(e(z)));var N=Ue();let te;var _e=p(N,!0);v(N),T(()=>{te=X(N,1,"action-item svelte-wh9uu8",null,te,{selected:e(ee)===e(s)}),I(_e,e(z).label)}),Qe("mouseenter",N,()=>r(s,e(ee),!0)),M("click",N,()=>e(z).action()),m(ge,N)}),T(()=>I(me,e(G).category)),m(x,$)});var E=w(P,2);{var fe=x=>{var G=We();m(x,G)};R(E,x=>{e(S).length===0&&x(fe)})}v(D),ie(c,()=>e(u),x=>r(u,x)),m(a,n)};R(A,a=>{e(t)?a(L):a(H,-1)})}v(F),v(f),M("keydown",f,C),M("click",f,function(...a){o.onClose?.apply(this,a)}),M("click",F,a=>a.stopPropagation()),m(h,f),Z()}de(["keydown","click"]);var Ze=_('<div class="shell svelte-w96i92"><!> <main class="main-content svelte-w96i92"><!></main></div> <!>',1);function $e(h,o){Y(o,!0);let l=q(!1),u=q(!1);function s(d){d.key==="k"&&(d.metaKey||d.ctrlKey)&&(d.preventDefault(),r(u,!e(u)))}ve(()=>{window.addEventListener("keydown",s)}),Se(()=>{window.removeEventListener("keydown",s)});var t=Ze(),i=O(t),g=p(i);ze(g,{get collapsed(){return e(l)},set collapsed(d){r(l,d,!0)}});var b=w(g,2),S=p(b);pe(S,()=>o.children),v(b),v(i);var Q=w(i,2);{var k=d=>{Ye(d,{onClose:()=>r(u,!1)})};R(Q,d=>{e(u)&&d(k)})}m(h,t),Z()}function ft(h,o){Me("12qhfyh",l=>{De(()=>{qe.title="Fellowship Dashboard"})}),$e(h,{children:(l,u)=>{var s=Pe(),t=O(s);pe(t,()=>o.children),m(l,s)},$$slots:{default:!0}})}export{ft as component,pt as universal}; | |||
There was a problem hiding this comment.
Guard command submissions and surface failures.
Line 1 routes every Enter press to the async dispatcher, clears the active command only after the awaited API returns, and then swallows any exception with catch {}. A fast double-press can enqueue duplicate spawn/kill/restart commands, and backend failures disappear silently. Please add a synchronous in-flight guard plus a visible error state in the Svelte source, then regenerate this bundle.
The command matcher is substring-only, not fuzzy.
Line 1 filters with label.toLowerCase().includes(query), so queries like sq for “Spawn Quest” or cfg for “Go to Config” will never match. That falls short of the PR’s advertised fuzzy-search behavior.
Avoid modulo-by-zero when there are no matches.
Line 1 computes (selected ± 1) % results.length on ArrowUp/ArrowDown without checking results.length. When the filtered list is empty, this writes NaN into the selection state and leaves keyboard navigation in a broken state until the query changes.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import"../chunks/69_IOA4Y.js";import{f as F,t as y,d as a,g as t,s as r,l as h,a0 as ne,p as K,b as N,$ as oe,a1 as ve,L as J}from"../chunks/CFIQ5hjj.js";import{s as V,a as q}from"../chunks/DLS4WeON.js";import{c as ie,a as p,f as m,s as k,d as le,b as E}from"../chunks/ByVGy8qs.js";import{i as A}from"../chunks/537vdszY.js";import{e as L,i as de}from"../chunks/zKsXIgWY.js";import{i as ce}from"../chunks/Nm4YapQn.js";import{f as ue,d as pe,s as me,a as _e,g as ge,p as fe,h as he,i as xe,e as be,b as qe,c as ke,q as we}from"../chunks/BjyTiIPo.js";import{H as ye,s as Se,t as He,a as Pe}from"../chunks/BTNO_X98.js";import{p as X}from"../chunks/BVGfD6Eb.js";import{s as T}from"../chunks/CQvy7xpd.js";import{g as Ce}from"../chunks/loYStGme.js";import{a as Ge}from"../chunks/DdAmTr_Q.js";import{G as Qe}from"../chunks/CMrUtYuQ.js";var $e=m('<div class="banner svelte-1etvnwu">Disconnected — reconnecting...</div>');function Ae(f){const e=()=>q(ue,"$connected",_),[_,s]=V();var l=ie(),v=F(l);{var c=n=>{var u=$e();p(n,u)};A(v,n=>{e()||n(c)})}p(f,l),s()}var Ie=m('<div class="stat svelte-1em4u7y"><div class="stat-value svelte-1em4u7y"> </div> <div class="stat-label svelte-1em4u7y"> </div></div>');function D(f,e){let _=X(e,"color",3,"var(--text-primary)");var s=Ie(),l=a(s);let v;var c=a(l,!0);t(l);var n=r(l,2),u=a(n,!0);t(n),t(s),y(()=>{v=pe(l,"",v,{color:_()}),k(c,e.value),k(u,e.label)}),p(f,s)}var Oe=m("<div></div>"),Re=m("<div></div>");function We(f,e){let _=X(e,"compact",3,!1);const s=["Onboard","Research","Plan","Implement","Review","Complete"];function l(n){const u=s.indexOf(e.phase),d=s.indexOf(n);return d<u?"done":d===u?"current":"pending"}var v=Re();let c;L(v,21,()=>s,de,(n,u)=>{const d=ne(()=>l(h(u)));var x=Oe();y(()=>{T(x,1,`phase-bar ${h(d)??""}`,"svelte-16oavfg"),Ge(x,"title",h(u))}),p(n,x)}),t(v),y(()=>c=T(v,1,"timeline svelte-16oavfg",null,c,{compact:_()})),p(f,v)}var De=m("<span> </span>"),Te=m('<span class="errand-count"> </span>'),Be=m('<div class="gate-row svelte-1cd3txo"><!></div>'),Ee=m('<div role="link" tabindex="0"><div class="card-header svelte-1cd3txo"><span class="quest-name svelte-1cd3txo"> </span> <!></div> <!> <div class="card-meta svelte-1cd3txo"><span class="phase-label svelte-1cd3txo"> </span> <!></div> <!></div>');function Fe(f,e){K(e,!0);function _(){Ce(`/quest/${encodeURIComponent(e.quest.name)}`)}var s=Ee();let l;var v=a(s),c=a(v),n=a(c,!0);t(c);var u=r(c,2);{var d=o=>{var i=De(),b=a(i,!0);t(i),y(()=>{T(i,1,`health-badge ${e.health.health??""}`,"svelte-1cd3txo"),k(b,e.health.health)}),p(o,i)};A(u,o=>{e.health&&o(d)})}t(v);var x=r(v,2);We(x,{get phase(){return e.quest.phase},compact:!0});var P=r(x,2),S=a(P),I=a(S,!0);t(S);var O=r(S,2);{var C=o=>{var i=Te(),b=a(i);t(i),y(()=>k(b,`${e.quest.errands_done??""}/${e.quest.errands_total??""} errands`)),p(o,i)};A(O,o=>{e.quest.errands_total>0&&o(C)})}t(P);var R=r(P,2);{var W=o=>{var i=Be(),b=a(i);Qe(b,{get worktree(){return e.quest.worktree}}),t(i),E("click",i,G=>G.stopPropagation()),p(o,i)};A(R,o=>{e.quest.gate_pending&&o(W)})}t(s),y(()=>{l=T(s,1,"quest-card svelte-1cd3txo",null,l,{"gate-pending":e.quest.gate_pending}),k(n,e.quest.name),k(I,e.quest.phase)}),E("click",s,_),E("keydown",s,o=>o.key==="Enter"&&_()),p(f,s),N()}le(["click","keydown"]);var Le=m('<div class="scout-card svelte-1x14rpa"><div class="scout-header svelte-1x14rpa"><span class="scout-icon">📡</span> <span class="scout-name svelte-1x14rpa"> </span></div> <p class="scout-question svelte-1x14rpa"> </p></div>'),Me=m("<!> <!>",1),Ue=m('<div class="empty-state svelte-1x14rpa"><p>Waiting for fellowship data...</p></div>'),je=m('<!> <div class="command-view svelte-1x14rpa"><div class="view-header svelte-1x14rpa"><h1 class="svelte-1x14rpa">Command</h1></div> <div class="stats-row svelte-1x14rpa"><!> <!> <!> <!></div> <div class="content-split svelte-1x14rpa"><div class="quest-grid svelte-1x14rpa"><!></div> <div class="herald-panel svelte-1x14rpa"><div class="panel-header svelte-1x14rpa">Herald</div> <!></div></div></div>',1);function vt(f,e){K(e,!1);const _=()=>q(we,"$questHealths",d),s=()=>q(ge,"$activeQuests",d),l=()=>q(fe,"$pendingGates",d),v=()=>q(he,"$activeScouts",d),c=()=>q(xe,"$problems",d),n=()=>q(be,"$dashboardStatus",d),u=()=>q(He,"$tidings",d),[d,x]=V();oe(()=>{me(),_e(),Se()}),ve(()=>{qe(),ke(),Pe()});function P(g){return _().find(H=>H.name===g)}ce();var S=je(),I=F(S);Ae(I);var O=r(I,2),C=r(a(O),2),R=a(C);D(R,{get value(){return s().length},label:"Active Quests",color:"var(--accent-green-text)"});var W=r(R,2);D(W,{get value(){return l().length},label:"Gates Pending",color:"var(--accent-gold)"});var o=r(W,2);D(o,{get value(){return v().length},label:"Scouts Active",color:"var(--accent-blue)"});var i=r(o,2);{let g=J(()=>c().length>0?"var(--accent-red)":"var(--accent-purple)");D(i,{get value(){return c().length},label:"Alerts",get color(){return h(g)}})}t(C);var b=r(C,2),G=a(b),Y=a(G);{var Z=g=>{var H=Me(),U=F(H);L(U,1,()=>n().quests,w=>w.name,(w,Q)=>{{let $=J(()=>P(h(Q).name));Fe(w,{get quest(){return h(Q)},get health(){return h($)}})}});var ae=r(U,2);L(ae,1,()=>n().scouts,w=>w.name,(w,Q)=>{var $=Le(),B=a($),j=r(a(B),2),se=a(j,!0);t(j),t(B);var z=r(B,2),re=a(z,!0);t(z),t($),y(()=>{k(se,h(Q).name),k(re,h(Q).question)}),p(w,$)}),p(g,H)},ee=g=>{var H=Ue();p(g,H)};A(Y,g=>{n()?g(Z):g(ee,-1)})}t(G);var M=r(G,2),te=r(a(M),2);ye(te,{get tidings(){return u()}}),t(M),t(b),t(O),p(f,S),N(),x()}export{vt as component}; | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if build artifacts are gitignored or if there's a build script
echo "=== Checking .gitignore for static assets ==="
fd -t f '.gitignore' --exec cat {} \; 2>/dev/null | grep -E '(_app|static|dist|build)' || echo "No relevant gitignore patterns found"
echo ""
echo "=== Looking for build scripts or Makefile ==="
fd -t f -e 'Makefile' -e 'makefile' --exec cat {} \; 2>/dev/null | head -50
fd -t f 'build.sh' --exec cat {} \; 2>/dev/null | head -50
echo ""
echo "=== Checking for CI workflow files ==="
fd -t f -e 'yml' -e 'yaml' -p '.github/workflows' --exec cat {} \; 2>/dev/null | head -100Repository: justinjdev/fellowship
Length of output: 506
Add .gitignore rule and enforce the build step in CI.
A build script already exists (npm run build + copy to cli/internal/dashboard/static/), which is good. However, the current setup relies on developers remembering to rebuild before committing:
- Build artifacts are committed despite being auto-generated (no
.gitignorerule prevents this) - No CI workflow enforces running the build step
- This creates risk of source/artifact mismatch if the rebuild is skipped
Strengthen the setup by:
- Add
cli/internal/dashboard/static/_app/to.gitignore - Call the build script in CI before the Go build step (ensures the binary is always in sync)
This keeps the source of truth clear while preventing merge conflicts and stale artifacts.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{d as a,l as s,g as t,s as r,t as g,p as Ts,i as Cs,F as w,b as Fs,a0 as M,_ as Q,f as us}from"../chunks/CFIQ5hjj.js";import{s as Gs,a as ls}from"../chunks/DLS4WeON.js";import{s as p,a as v,t as ms,f as c,d as Os,b as D}from"../chunks/ByVGy8qs.js";import{i as H}from"../chunks/537vdszY.js";import{e as U,i as rs}from"../chunks/zKsXIgWY.js";import{a as Is}from"../chunks/DdAmTr_Q.js";import{s as y}from"../chunks/CQvy7xpd.js";import{p as Ns}from"../chunks/OlN70tzR.js";import{e as Ps,q as As}from"../chunks/BjyTiIPo.js";import{c as Bs,d as Ds}from"../chunks/C67DyfnJ.js";import{G as Ms}from"../chunks/CMrUtYuQ.js";import{H as Qs,t as Us}from"../chunks/BTNO_X98.js";var Ws=c('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),js=c('<div class="errand-list svelte-6y5tqw"></div>');function zs(W,j){var N=js();U(N,21,()=>j.errands,P=>P.id,(P,S)=>{var E=Ws(),O=a(E),L=a(O);{var n=m=>{var i=ms("✓");v(m,i)},k=m=>{var i=ms("●");v(m,i)};H(L,m=>{s(S).status==="done"?m(n):s(S).status==="active"&&m(k,1)})}t(O);var A=r(O,2),q=a(A,!0);t(A),t(E),g(()=>{y(O,1,`errand-check ${s(S).status??""}`,"svelte-6y5tqw"),y(A,1,`errand-text ${s(S).status??""}`,"svelte-6y5tqw"),p(q,s(S).description)}),v(P,E)}),t(N),v(W,N)}var Js=c('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Ks=c('<div><span class="phase-step-label"> </span></div>'),Vs=c('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Xs=c('<span class="tab-badge svelte-1ysin3l"> </span>'),Ys=c('<div class="file-item svelte-1ysin3l"> </div>'),Zs=c('<div class="files-list svelte-1ysin3l"></div>'),se=c('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),ee=c('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),te=c('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),ae=c('<p class="empty svelte-1ysin3l">No data available</p>'),le=c('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),re=c('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ie=c('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ge(W,j){Ts(j,!0);const N=()=>ls(Ps,"$dashboardStatus",E),P=()=>ls(As,"$questHealths",E),S=()=>ls(Us,"$tidings",E),[E,O]=Gs();let L=M(()=>decodeURIComponent(Ns.params.id)),n=M(()=>N()?.quests.find(e=>e.name===s(L))),k=M(()=>P().find(e=>e.name===s(L))),A=M(()=>S().filter(e=>e.quest===s(L))),q=Q(null),m=Q(null),i=Q("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let J=Q(!1);Cs(()=>{s(n)&&!s(J)&&(w(J,!0),Promise.all([Bs(s(n).worktree),Ds(s(L))]).then(([e,l])=>{w(q,e,!0),w(m,l,!0)}).catch(()=>{w(J,!1)}))});function fs(e){if(!s(n))return"pending";const l=z.indexOf(s(n).phase),o=z.indexOf(e);return o<l?"done":o===l?"current":"pending"}var K=ie(),V=a(K),X=a(V),is=r(a(X),4),hs=a(is,!0);t(is),t(X);var ys=r(X,2);{var bs=e=>{var l=Vs(),o=us(l),d=a(o),_=a(d,!0);t(d);var f=r(d,2),b=a(f,!0);t(f);var $=r(f,2);{var R=h=>{Ms(h,{get worktree(){return s(n).worktree}})};H($,h=>{s(n).gate_pending&&h(R)})}var T=r($,2);{var C=h=>{var F=Js(),u=us(F),x=r(u,2),G=a(x,!0);t(x),g(()=>{y(u,1,`health-dot ${s(k).health??""}`,"svelte-1ysin3l"),p(G,s(k).health)}),v(h,F)};H(T,h=>{s(k)&&h(C)})}t(o);var I=r(o,2);U(I,21,()=>z,rs,(h,F)=>{var u=Ks(),x=a(u),G=a(x,!0);t(x),t(u),g(as=>{y(u,1,`phase-step ${as??""}`,"svelte-1ysin3l"),p(G,s(F))},[()=>fs(s(F))]),v(h,u)}),t(I),g(()=>{p(_,s(n).name),p(b,s(n).phase)}),v(e,l)};H(ys,e=>{s(n)&&e(bs)})}t(V);var Y=r(V,2),B=a(Y);let vs;var gs=r(a(B));{var xs=e=>{var l=Xs(),o=a(l);t(l),g(d=>p(o,`${d??""}/${s(q).items.length??""}`),[()=>s(q).items.filter(d=>d.status==="done").length]),v(e,l)};H(gs,e=>{s(q)&&e(xs)})}t(B);var Z=r(B,2);let ns;var ss=r(Z,2);let os;var es=r(ss,2);let ds;var ps=r(es,2);let cs;t(Y);var _s=r(Y,2),ts=a(_s),ws=a(ts);{var ks=e=>{zs(e,{get errands(){return s(q).items}})},qs=e=>{var l=Zs();U(l,21,()=>s(m).files_touched,rs,(o,d)=>{var _=Ys(),f=a(_,!0);t(_),g(()=>p(f,s(d))),v(o,_)}),t(l),v(e,l)},$s=e=>{var l=ee(),o=r(a(l),2);U(o,17,()=>s(m).gate_history,rs,(d,_)=>{var f=se(),b=a(f),$=a(b,!0);t(b);var R=r(b,2),T=a(R,!0);t(R);var C=r(R,2),I=a(C,!0);t(C),t(f),g(()=>{y(b,1,`tome-action ${s(_).action??""}`,"svelte-1ysin3l"),p($,s(_).action),p(T,s(_).phase),p(I,s(_).timestamp)}),v(d,f)}),t(l),v(e,l)},Hs=e=>{Qs(e,{get tidings(){return s(A)},limit:50})},Ss=e=>{var l=te();v(e,l)},Es=e=>{var l=ae();v(e,l)};H(ws,e=>{s(i)==="errands"&&s(q)?e(ks):s(i)==="files"&&s(m)?e(qs,1):s(i)==="tome"&&s(m)?e($s,2):s(i)==="herald"?e(Hs,3):s(i)==="logs"?e(Ss,4):e(Es,-1)})}t(ts);var Ls=r(ts,2);{var Rs=e=>{var l=re(),o=a(l),d=r(a(o),2),_=r(a(d),2),f=a(_,!0);t(_),t(d);var b=r(d,2),$=r(a(b),2),R=a($,!0);t($),t(b);var T=r(b,2),C=r(a(T),2),I=a(C,!0);t(C),t(T);var h=r(T,2);{var F=u=>{var x=le(),G=r(a(x),2),as=a(G,!0);t(G),t(x),g(()=>{y(G,1,`meta-value ${s(k).health??""}`,"svelte-1ysin3l"),p(as,s(k).health)}),v(u,x)};H(h,u=>{s(k)&&u(F)})}t(o),t(l),g(u=>{p(f,u),Is($,"title",s(n).worktree),p(R,s(n).worktree),p(I,s(n).status)},[()=>s(n).worktree.split("/").pop()]),v(e,l)};H(Ls,e=>{s(n)&&e(Rs)})}t(_s),t(K),g(()=>{p(hs,s(L)),vs=y(B,1,"tab svelte-1ysin3l",null,vs,{active:s(i)==="errands"}),ns=y(Z,1,"tab svelte-1ysin3l",null,ns,{active:s(i)==="files"}),os=y(ss,1,"tab svelte-1ysin3l",null,os,{active:s(i)==="tome"}),ds=y(es,1,"tab svelte-1ysin3l",null,ds,{active:s(i)==="herald"}),cs=y(ps,1,"tab svelte-1ysin3l",null,cs,{active:s(i)==="logs"})}),D("click",B,()=>w(i,"errands")),D("click",Z,()=>w(i,"files")),D("click",ss,()=>w(i,"tome")),D("click",es,()=>w(i,"herald")),D("click",ps,()=>w(i,"logs")),v(W,K),Fs(),O()}Os(["click"]);export{ge as component}; | |||
There was a problem hiding this comment.
Don’t derive the Branch label with split('/') only.
Line 1 uses worktree.split("/").pop() for the metadata branch name. If the Go backend returns a Windows path, this renders the full C:\... string instead of the basename. Normalize both separators here, or better return the branch name explicitly from the API.
Replace the one-shot fetch flag with request-keyed state.
Line 1’s Cs(...) effect sets J before Promise.all, never re-keys/clears it after a successful load, and only flips it back on error. That makes the detail payload stale after a quest/worktree change and can hammer the backend with immediate retries when either request fails. Please track the active {id, worktree} and keep separate loading/error state in the Svelte source that generates this bundle.
🔧 Source-level fix sketch
-let loaded = false;
-$effect(() => {
- if (quest && !loaded) {
- loaded = true;
- Promise.all([loadErrands(quest.worktree), loadDetails(id)])
- .then(([nextErrands, nextDetails]) => {
- errands = nextErrands;
- details = nextDetails;
- })
- .catch(() => {
- loaded = false;
- });
- }
-});
+let activeKey = '';
+let loading = false;
+let error = null;
+
+$effect(() => {
+ const quest = $dashboardStatus?.quests.find((q) => q.name === id);
+ const key = quest ? `${id}:${quest.worktree}` : '';
+ if (!key || key === activeKey || loading) return;
+
+ activeKey = key;
+ errands = null;
+ details = null;
+ error = null;
+ loading = true;
+
+ Promise.all([loadErrands(quest.worktree), loadDetails(id)])
+ .then(([nextErrands, nextDetails]) => {
+ if (activeKey !== key) return;
+ errands = nextErrands;
+ details = nextDetails;
+ })
+ .catch((e) => {
+ if (activeKey === key) error = e;
+ })
+ .finally(() => {
+ if (activeKey === key) loading = false;
+ });
+});88abd8d to
4f8303b
Compare
Add gorilla/websocket-based Hub that manages connections and broadcasts events. Wire into existing gate approve/reject handlers so dashboard clients receive live notifications on quest state changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
File-backed command queue supporting spawn-quest, spawn-scout, kill-quest, and restart-quest actions with pending/completed/failed status tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New endpoints enqueue commands to the file-backed queue and broadcast
WebSocket events: POST /api/quest/{spawn,kill,restart},
POST /api/scout/spawn, GET /api/commands.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New endpoints: GET /api/autopsies (list/detail), GET /api/tome/<quest> for quest history, GET/POST /api/config for reading and writing fellowship configuration at global or project scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace root handler with SPA-aware routing that serves static files when they exist and falls back to index.html for unmatched paths, enabling client-side routing in the dashboard frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover Hub broadcast with no connections, command enqueue with persistence verification, and empty queue loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…riven refresh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…actions, herald, connection banner) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… badge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e, and herald tabs
…avigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously, WSEvent broadcasts from gate approve/reject handlers and command enqueue handlers omitted the Timestamp field, resulting in zero values. Clients relying on event timestamps for ordering would see incorrect data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Sanitize autopsy filename to prevent path traversal (reject non-base filenames) - Handle json.MarshalIndent error instead of discarding - Guard arrow-key navigation in CommandPalette when filtered list is empty - Prevent infinite retry loop on quest detail page fetch failure - Handle unknown phase in phaseStatus (return pending instead of marking all done) - Normalize backslashes in branch label path splitting - Self-host Google Fonts to eliminate CDN dependency (embedded binary model) - Rebuild dashboard static assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix CommandPalette submitInput logic bug: success-path code was outside try block, running even after failures - Fix $derived(() => ...) to $derived.by() in CommandPalette grouped - Add Content-Type: application/json headers to all JSON responses - Clean up orphaned tmp file on config rename failure - Sanitize internal error details in HTTP responses - Add double-call guards for startPolling/startHeraldPolling to prevent subscription leaks - Add dataLoaded guard to timeline to prevent redundant tome fetching - Guard against null/undefined tags in autopsies search filter - Fix quest detail page race condition: set dataLoaded after fetch completes, add loading guard to prevent concurrent fetches - Rebuild dashboard static assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Define zombie classification in rekindle recovery flow - Fix <main_repo> → <repo_root> placeholder in SKILL.md - Handle UserHomeDir failure gracefully in config read - Add all 8 herald tiding types to filter UI - Guard NaN dates in GanttChart timeline - Guard arrow-key navigation when command palette is empty - Fallback for branch label when path split yields empty Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add gitRoot field back to Server for file-based operations (command queue, config read/write) not yet migrated to SQLite - Port autopsy handler to use autopsy.Scan instead of removed file-based LoadByID/List functions - Port tome handler to use tome.Load with SQLite connection - Add Timestamp field to WSEvent broadcasts (from branch) - Update NewServer callers with gitRoot parameter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5c92fcd to
62d5cc1
Compare
|
@coderabbitai resume |
✅ Actions performedReviews resumed. |
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (3)
cli/internal/dashboard/data.go (1)
126-128:⚠️ Potential issue | 🟡 MinorHandle
UserHomeDirerror for global scope writes.The error from
os.UserHomeDir()is still ignored on line 127. If$HOMEis unset (e.g., containerized environments),homeis empty andconfigPathbecomes"/.claude/fellowship.json", attempting a write to filesystem root. The read path (line 89) correctly handles this, but the write path does not.🛡️ Proposed fix
case "global": - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + http.Error(w, "cannot determine home directory for global config", http.StatusInternalServerError) + return + } configPath = filepath.Join(home, ".claude", "fellowship.json")dashboard/src/lib/components/CommandPalette.svelte (1)
100-104:⚠️ Potential issue | 🟠 MajorUse a real fuzzy matcher here.
This is still contiguous substring matching, so queries like
sq/rqwill never findSpawn Quest/Restart Quest. That misses the fuzzy-search behavior this PR claims to add.Suggested direction
+ function fuzzyMatch(query: string, label: string): boolean { + const needle = query.toLowerCase().replace(/\s+/g, ''); + const haystack = label.toLowerCase(); + let i = 0; + for (const ch of haystack) { + if (ch === needle[i]) i++; + if (i === needle.length) return true; + } + return needle.length === 0; + } + let filtered = $derived( - query - ? actions.filter((a) => a.label.toLowerCase().includes(query.toLowerCase())) - : actions + query ? actions.filter((a) => fuzzyMatch(query, a.label)) : actions );cli/internal/dashboard/queue.go (1)
50-54:⚠️ Potential issue | 🟡 MinorHandle error from
rand.Read.The code ignores the error return value from
rand.Read(). While rare, this call can fail due to entropy depletion or system errors, potentially resulting in predictable or incomplete command IDs.Proposed fix
func generateID() string { b := make([]byte, 8) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand.Read failed: %v", err)) + } return hex.EncodeToString(b) }
🧹 Nitpick comments (9)
cli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.js (1)
1-1: Consider adding fetch timeouts for resilience.The API client functions use
fetchwithout timeouts. If the backend becomes unresponsive, these calls will hang indefinitely, potentially degrading user experience.For critical paths, consider using
AbortControllerwith a timeout:Example pattern
async function fetchWithTimeout(url, options, timeout = 10000) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(id); } }dashboard/src/routes/timeline/+page.svelte (2)
13-25:dataLoadedflag set before fetch completes could mask status updates.Setting
dataLoaded = trueon Line 16 before the asyncPromise.allSettledcompletes means ifdashboardStatusemits again during the fetch, the new status is ignored. If the intent is one-time load, this is fine. If quests can be added dynamically and should trigger re-fetch, consider settingdataLoaded = trueafter the fetch or using a different approach.Also, the
as QuestTome[]cast on Line 23 is redundant since the preceding type guard already narrows the type.Suggested simplification
tomes = results .filter((r): r is PromiseFulfilledResult<unknown> => r.status === 'fulfilled') .map((r) => r.value) - .filter((t): t is QuestTome => t != null) as QuestTome[]; + .filter((t): t is QuestTome => t != null); loading = false; }
38-42: Consider stopping WebSocket/polling only when navigating away from real-time views.The
onDestroystops WebSocket and polling when leaving the timeline page. If other pages (like/commandor/herald) also need these connections, each page redundantly starts/stops them. This works but causes unnecessary reconnection overhead when navigating between real-time views.dashboard/src/routes/herald/+page.svelte (1)
40-52: Filter logic: unchecking all type checkboxes shows all tidings.The
anyTypeEnabledcheck on Line 43-44 means when no checkboxes are checked,anyTypeEnabledis false, so the type filter is bypassed and all types are shown. This may be intentional (showing everything when nothing selected) but could surprise users expecting an empty result. Consider whether this UX matches user expectations.dashboard/src/lib/stores/herald.ts (1)
7-12: Silent error handling hides potential issues.The empty
catchblock on Line 11 swallows all errors, including JSON parse failures or unexpected response formats. While this is acceptable for network failures when offline, it could mask server-side bugs or API contract violations during development.Suggested improvement
export async function fetchTidings() { try { const res = await fetch('/api/herald'); - if (res.ok) tidings.set(await res.json()); - } catch { /* offline */ } + if (res.ok) { + tidings.set(await res.json()); + } + } catch (err) { + // Silently ignore fetch failures (e.g., offline) + if (import.meta.env.DEV) { + console.warn('[herald] fetch failed:', err); + } + } }dashboard/src/lib/stores/websocket.ts (1)
36-46: Guard against duplicate connection attempts during CONNECTING state.The early return only checks for
WebSocket.OPEN, but ifconnect()is called while a socket is still inCONNECTINGstate, a new socket will be created, potentially causing duplicate connections.🛠️ Proposed fix
function connect() { - if (ws?.readyState === WebSocket.OPEN) return; + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; let socket: WebSocket;cli/internal/dashboard/ws.go (1)
81-89: Consider adding a read deadline to detect half-open connections.The read pump runs indefinitely without a read deadline. If a client becomes unresponsive without properly closing the TCP connection (half-open state), this goroutine may hang indefinitely. Setting a read deadline with periodic pings would allow detection of such connections.
🛠️ Proposed enhancement
// Read pump — just drain pings/pongs, we don't expect client messages go func() { defer h.Remove(conn) + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) for { if _, _, err := conn.ReadMessage(); err != nil { break } } }()Note: This would require a corresponding ping sender, which may be overkill for a localhost dashboard.
dashboard/src/lib/stores/quests.ts (1)
48-67: Consider debouncing rapid WebSocket events to avoid redundant API calls.When multiple events arrive in quick succession (e.g.,
quest-changedfollowed immediately bygate-resolved), each triggers a separaterefreshAll()call. This could result in redundant concurrent API requests.🛠️ Proposed debounce approach
+let refreshTimeout: ReturnType<typeof setTimeout> | null = null; + +function debouncedRefreshAll() { + if (refreshTimeout) clearTimeout(refreshTimeout); + refreshTimeout = setTimeout(() => { + refreshAll(); + refreshTimeout = null; + }, 100); +} + export function startPolling() { if (unsubscribe) return; refreshAll(); unsubscribe = lastEvent.subscribe((event) => { if (!event) return; switch (event.type) { case 'quest-changed': case 'gate-submitted': case 'gate-resolved': case 'command-completed': - refreshAll(); + debouncedRefreshAll(); break;cli/internal/dashboard/queue.go (1)
88-108: LGTM on concurrency, but consider queue size management.The mutex-protected enqueue logic is correct. However, the queue will grow indefinitely as commands are only appended. For long-running deployments, consider periodically pruning completed/failed commands older than a threshold.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 21e048dd-c1db-4616-87cd-e8257eef0f18
⛔ Files ignored due to path filters (32)
cli/go.sumis excluded by!**/*.sumcli/internal/dashboard/static/fonts/cinzel-400.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/cinzel-600.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/cinzel-700.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/dm-sans-300.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/dm-sans-400.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/dm-sans-500.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/dm-sans-600.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/jetbrains-mono-300.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/jetbrains-mono-400.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/jetbrains-mono-500.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/outfit-300.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/outfit-400.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/outfit-500.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/outfit-600.ttfis excluded by!**/*.ttfcli/internal/dashboard/static/fonts/outfit-700.ttfis excluded by!**/*.ttfdashboard/package-lock.jsonis excluded by!**/package-lock.jsondashboard/static/fonts/cinzel-400.ttfis excluded by!**/*.ttfdashboard/static/fonts/cinzel-600.ttfis excluded by!**/*.ttfdashboard/static/fonts/cinzel-700.ttfis excluded by!**/*.ttfdashboard/static/fonts/dm-sans-300.ttfis excluded by!**/*.ttfdashboard/static/fonts/dm-sans-400.ttfis excluded by!**/*.ttfdashboard/static/fonts/dm-sans-500.ttfis excluded by!**/*.ttfdashboard/static/fonts/dm-sans-600.ttfis excluded by!**/*.ttfdashboard/static/fonts/jetbrains-mono-300.ttfis excluded by!**/*.ttfdashboard/static/fonts/jetbrains-mono-400.ttfis excluded by!**/*.ttfdashboard/static/fonts/jetbrains-mono-500.ttfis excluded by!**/*.ttfdashboard/static/fonts/outfit-300.ttfis excluded by!**/*.ttfdashboard/static/fonts/outfit-400.ttfis excluded by!**/*.ttfdashboard/static/fonts/outfit-500.ttfis excluded by!**/*.ttfdashboard/static/fonts/outfit-600.ttfis excluded by!**/*.ttfdashboard/static/fonts/outfit-700.ttfis excluded by!**/*.ttf
📒 Files selected for processing (99)
.gitignorecli/cmd/fellowship/main.gocli/go.modcli/internal/dashboard/commands.gocli/internal/dashboard/data.gocli/internal/dashboard/embed.gocli/internal/dashboard/queue.gocli/internal/dashboard/queue_test.gocli/internal/dashboard/server.gocli/internal/dashboard/server_test.gocli/internal/dashboard/static/_app/env.jscli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.csscli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.csscli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.csscli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.csscli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.csscli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.csscli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.csscli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.csscli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.csscli/internal/dashboard/static/_app/immutable/chunks/537vdszY.jscli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.jscli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.jscli/internal/dashboard/static/_app/immutable/chunks/BVGfD6Eb.jscli/internal/dashboard/static/_app/immutable/chunks/ByVGy8qs.jscli/internal/dashboard/static/_app/immutable/chunks/C67DyfnJ.jscli/internal/dashboard/static/_app/immutable/chunks/CFIQ5hjj.jscli/internal/dashboard/static/_app/immutable/chunks/CJ8mMi92.jscli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.jscli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.jscli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.jscli/internal/dashboard/static/_app/immutable/chunks/CaohEQ9u.jscli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.jscli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.jscli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.jscli/internal/dashboard/static/_app/immutable/chunks/DeY8KYDG.jscli/internal/dashboard/static/_app/immutable/chunks/DgDK1e17.jscli/internal/dashboard/static/_app/immutable/chunks/DmL0_esv.jscli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.jscli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.jscli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.jscli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.jscli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.jscli/internal/dashboard/static/_app/immutable/entry/app.BdcOekXH.jscli/internal/dashboard/static/_app/immutable/entry/start.B5-0ZWIv.jscli/internal/dashboard/static/_app/immutable/nodes/0.DBOxo5Wb.jscli/internal/dashboard/static/_app/immutable/nodes/1.ByZ8u1CK.jscli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.jscli/internal/dashboard/static/_app/immutable/nodes/3.Dot0CBpK.jscli/internal/dashboard/static/_app/immutable/nodes/4.4T6CzQcj.jscli/internal/dashboard/static/_app/immutable/nodes/5.VZsWOulv.jscli/internal/dashboard/static/_app/immutable/nodes/6.BzB38D4G.jscli/internal/dashboard/static/_app/immutable/nodes/7.C5EwJyeT.jscli/internal/dashboard/static/_app/immutable/nodes/8.BFyONnpl.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/app.jscli/internal/dashboard/static/fonts/fonts.csscli/internal/dashboard/static/index.htmlcli/internal/dashboard/static/style.csscli/internal/dashboard/ws.gocli/internal/dashboard/ws_test.godashboard/.gitignoredashboard/build.shdashboard/package.jsondashboard/src/app.htmldashboard/src/lib/api.tsdashboard/src/lib/components/CommandPalette.sveltedashboard/src/lib/components/ConnectionBanner.sveltedashboard/src/lib/components/ErrandList.sveltedashboard/src/lib/components/GanttChart.sveltedashboard/src/lib/components/GateActions.sveltedashboard/src/lib/components/HeraldFeed.sveltedashboard/src/lib/components/PhaseTimeline.sveltedashboard/src/lib/components/QuestCard.sveltedashboard/src/lib/components/Shell.sveltedashboard/src/lib/components/Sidebar.sveltedashboard/src/lib/components/StatCounter.sveltedashboard/src/lib/stores/herald.tsdashboard/src/lib/stores/quests.tsdashboard/src/lib/stores/websocket.tsdashboard/src/lib/styles/global.cssdashboard/src/lib/styles/tokens.cssdashboard/src/lib/types.tsdashboard/src/routes/+layout.jsdashboard/src/routes/+layout.sveltedashboard/src/routes/+page.jsdashboard/src/routes/+page.sveltedashboard/src/routes/autopsies/+page.sveltedashboard/src/routes/command/+page.sveltedashboard/src/routes/config/+page.sveltedashboard/src/routes/herald/+page.sveltedashboard/src/routes/quest/[id]/+page.sveltedashboard/src/routes/timeline/+page.sveltedashboard/static/fonts/fonts.cssdashboard/svelte.config.jsdashboard/tsconfig.jsondashboard/vite.config.tsplugin/commands/rekindle.mdplugin/skills/fellowship/SKILL.md
💤 Files with no reviewable changes (2)
- cli/internal/dashboard/static/style.css
- cli/internal/dashboard/static/app.js
✅ Files skipped from review due to trivial changes (42)
- cli/internal/dashboard/static/_app/immutable/chunks/DsnmJJEf.js
- .gitignore
- cli/internal/dashboard/static/_app/env.js
- dashboard/build.sh
- cli/internal/dashboard/static/_app/immutable/assets/4.DUdybRxg.css
- dashboard/.gitignore
- cli/internal/dashboard/static/_app/immutable/chunks/69_IOA4Y.js
- dashboard/src/routes/+page.js
- cli/internal/dashboard/static/_app/immutable/assets/3.CVgNbBqn.css
- cli/internal/dashboard/static/_app/immutable/entry/start.B5-0ZWIv.js
- dashboard/package.json
- cli/internal/dashboard/static/_app/immutable/chunks/DIeogL5L.js
- dashboard/tsconfig.json
- cli/internal/dashboard/static/_app/immutable/assets/GateActions.DvbP5nIs.css
- cli/internal/dashboard/static/_app/immutable/assets/HeraldFeed.D75iUDfw.css
- dashboard/src/routes/+layout.js
- dashboard/vite.config.ts
- cli/internal/dashboard/static/_app/immutable/chunks/DgDK1e17.js
- cli/internal/dashboard/queue_test.go
- dashboard/src/lib/styles/tokens.css
- dashboard/static/fonts/fonts.css
- cli/go.mod
- dashboard/src/app.html
- cli/internal/dashboard/static/_app/immutable/assets/6.DrszQgHF.css
- dashboard/src/routes/+layout.svelte
- dashboard/src/lib/styles/global.css
- cli/internal/dashboard/static/_app/immutable/assets/0.B4Tu4Ui9.css
- dashboard/src/lib/components/GateActions.svelte
- cli/internal/dashboard/static/_app/immutable/assets/5.BgjiOm-S.css
- cli/internal/dashboard/static/fonts/fonts.css
- cli/internal/dashboard/static/_app/immutable/assets/8.CA8t3wJx.css
- cli/internal/dashboard/static/_app/immutable/nodes/2.EjT9vOq6.js
- dashboard/svelte.config.js
- dashboard/src/routes/config/+page.svelte
- cli/internal/dashboard/static/_app/immutable/chunks/BUApaBEI.js
- cli/internal/dashboard/static/_app/immutable/entry/app.BdcOekXH.js
- cli/internal/dashboard/static/_app/immutable/assets/7.Ctb9NTgd.css
- dashboard/src/routes/autopsies/+page.svelte
- dashboard/src/routes/quest/[id]/+page.svelte
- cli/internal/dashboard/static/_app/immutable/chunks/Nm4YapQn.js
- cli/internal/dashboard/commands.go
- dashboard/src/lib/types.ts
🚧 Files skipped from review as they are similar to previous changes (21)
- cli/internal/dashboard/embed.go
- cli/internal/dashboard/static/_app/version.json
- cli/internal/dashboard/ws_test.go
- plugin/skills/fellowship/SKILL.md
- cli/internal/dashboard/static/index.html
- plugin/commands/rekindle.md
- dashboard/src/routes/command/+page.svelte
- cli/internal/dashboard/static/_app/immutable/chunks/CQvy7xpd.js
- dashboard/src/lib/components/PhaseTimeline.svelte
- dashboard/src/lib/components/ConnectionBanner.svelte
- dashboard/src/lib/components/GanttChart.svelte
- dashboard/src/lib/components/QuestCard.svelte
- cli/internal/dashboard/static/_app/immutable/chunks/CSXl7R3t.js
- cli/internal/dashboard/static/_app/immutable/chunks/jKnW4BRh.js
- cli/internal/dashboard/static/_app/immutable/chunks/CMrUtYuQ.js
- cli/internal/dashboard/static/_app/immutable/chunks/DdAmTr_Q.js
- dashboard/src/lib/components/ErrandList.svelte
- cli/internal/dashboard/static/_app/immutable/chunks/Dv0Hwark.js
- cli/internal/dashboard/static/_app/immutable/chunks/zKsXIgWY.js
- cli/internal/dashboard/server.go
- cli/internal/dashboard/static/_app/immutable/chunks/DLS4WeON.js
| placeholder={actions.find( | ||
| (a) => | ||
| a.label.toLowerCase().replace(/\s+/g, '-') === | ||
| inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-') | ||
| )?.inputPlaceholder ?? 'Enter value...'} |
There was a problem hiding this comment.
No-op replace calls in placeholder lookup logic.
The .replace() calls here replace strings with themselves and have no effect:
inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-')This appears to be dead code or a bug. The entire lookup is also unnecessarily complex when the action objects already have inputPlaceholder defined.
Simplified approach
<input
bind:this={taskInputEl}
bind:value={taskInput}
class="search-input"
- placeholder={actions.find(
- (a) =>
- a.label.toLowerCase().replace(/\s+/g, '-') ===
- inputMode?.replace('spawn-', 'spawn-').replace('kill-', 'kill-').replace('restart-', 'restart-')
- )?.inputPlaceholder ?? 'Enter value...'}
+ placeholder={inputMode === 'spawn-quest'
+ ? 'Enter task description...'
+ : inputMode === 'spawn-scout'
+ ? 'Enter question...'
+ : inputMode === 'kill-quest'
+ ? 'Enter quest ID...'
+ : inputMode === 'restart-quest'
+ ? 'Enter quest ID...'
+ : 'Enter value...'}
type="text"
/>Or store placeholders in a map keyed by inputMode values.
| if (searchText) { | ||
| const q = searchText.toLowerCase(); | ||
| const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase(); | ||
| if (!haystack.includes(q)) return false; | ||
| } |
There was a problem hiding this comment.
Search haystack may include literal "undefined" string.
If t.phase or t.detail is undefined, the template literal on Line 47 will include the string "undefined", causing false positive matches when users search for that term.
Proposed fix
if (searchText) {
const q = searchText.toLowerCase();
- const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase();
+ const haystack = `${t.quest ?? ''} ${t.type ?? ''} ${t.phase ?? ''} ${t.detail ?? ''}`.toLowerCase();
if (!haystack.includes(q)) return false;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (searchText) { | |
| const q = searchText.toLowerCase(); | |
| const haystack = `${t.quest} ${t.type} ${t.phase} ${t.detail}`.toLowerCase(); | |
| if (!haystack.includes(q)) return false; | |
| } | |
| if (searchText) { | |
| const q = searchText.toLowerCase(); | |
| const haystack = `${t.quest ?? ''} ${t.type ?? ''} ${t.phase ?? ''} ${t.detail ?? ''}`.toLowerCase(); | |
| if (!haystack.includes(q)) return false; | |
| } |
Replace bare `fellowship` CLI references with the pinned path `~/.claude/fellowship/bin/fellowship` per the documented convention. Addresses CodeRabbit review comments from PR #76. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ard shortcut - Add `ws !== socket` guard to onmessage and onerror handlers in websocket.ts to prevent stale socket instances from mutating shared state during reconnection races - Add CONNECTING state check to prevent duplicate connection attempts - Normalize keyboard shortcut key check with toLowerCase() for cross-platform robustness (Caps Lock edge case) Addresses CodeRabbit review comments from PR #76. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…#82) * fix: improve light theme color contrast for WCAG AA compliance - Darken accent from #8b6914 to #7a5a0e (4.1:1 → 5.1:1) - Darken scout from #4a8a9e to #377080 (3.3:1 → 4.7:1) - Darken monitor from #7b6ea4 to #635599 (3.4:1 → 4.8:1) - Update hover/focus colors to match new accent All light theme text/background pairs now pass WCAG AA (4.5:1+). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add WebSocket infrastructure for real-time dashboard updates Add gorilla/websocket-based Hub that manages connections and broadcasts events. Wire into existing gate approve/reject handlers so dashboard clients receive live notifications on quest state changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add command queue for dashboard-initiated actions File-backed command queue supporting spawn-quest, spawn-scout, kill-quest, and restart-quest actions with pending/completed/failed status tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add REST endpoints for quest/scout spawn, kill, restart commands New endpoints enqueue commands to the file-backed queue and broadcast WebSocket events: POST /api/quest/{spawn,kill,restart}, POST /api/scout/spawn, GET /api/commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): scaffold SvelteKit project with adapter-static Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add REST endpoints for autopsies, tome, and config New endpoints: GET /api/autopsies (list/detail), GET /api/tome/<quest> for quest history, GET/POST /api/config for reading and writing fellowship configuration at global or project scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add design system tokens and global styles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SPA fallback routing for client-side navigation Replace root handler with SPA-aware routing that serves static files when they exist and falls back to index.html for unmatched paths, enabling client-side routing in the dashboard frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add unit tests for WebSocket Hub and command queue Tests cover Hub broadcast with no connections, command enqueue with persistence verification, and empty queue loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add shell layout with collapsible sidebar and routing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add WebSocket store with exponential backoff reconnect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add type definitions and quest state store with WS-driven refresh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add herald event store Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add API helper with all REST endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add core UI components (stats, phase timeline, gate actions, herald, connection banner) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add QuestCard component with gate actions and health badge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): implement Command view with stats, quest grid, and herald feed * feat(dashboard): implement Quest Detail view with errands, files, tome, and herald tabs * feat(dashboard): add command palette with fuzzy search and keyboard navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): implement Herald view with filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): implement Autopsies view with search and drill-in Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): implement Timeline view with Gantt chart Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): implement Config view with editable settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(dashboard): add build script and embed Svelte output in Go binary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): embed _app directory and fix SPA static file serving - Use `all:static` in embed directive to include `_app/` directory (Go embed skips underscore-prefixed paths by default) - Fix SPA fallback to properly serve static files before falling back to index.html, reusing a single http.FileServer instance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address PR review feedback — error handling, concurrency, accessibility - Add mutex locking to EnqueueCommand for concurrent safety - Return error on corrupt command queue instead of silent empty fallback - Add collision-resistant random bytes to autopsy filenames - Use datadir.Name() instead of hardcoded .fellowship for autopsy paths - Reject malformed config JSON instead of silently overwriting - Add WebSocket write deadline to prevent blocking on unresponsive clients - Guard against stale WebSocket lifecycle events - Handle iofs.Sub error in static file serving - Fix gorilla/websocket marked as indirect in go.mod - Add res.ok checks to all API mutation functions - Fix btoa() Unicode safety for non-ASCII worktree paths - Wrap read API helpers in try/catch for network failures - Keep command palette open on failed commands - Reset GateActions loading state on API failure (try/finally) - Support Ctrl+K alongside Cmd+K for command palette - Convert QuestCard from <a> to <div> to fix nested button a11y violation - Use $effect instead of onMount for reactive quest data loading - Add NaN guard for invalid timestamps in HeraldFeed - Use Promise.allSettled for timeline tome fetching - Add error handling for autopsies fetch - Replace onMount redirect with SvelteKit redirect() in +page.js - Add :focus-visible outline for quest filter select - Use pinned CLI path in SKILL.md and rekindle.md - Replace undefined zombie classification with stale in rekindle.md - Rebuild dashboard static assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address PR review feedback round 2 - Add synchronous double-submit guard in GateActions approve/reject - Show error state instead of empty result on autopsies page load failure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): populate Timestamp on all WSEvent broadcasts Previously, WSEvent broadcasts from gate approve/reject handlers and command enqueue handlers omitted the Timestamp field, resulting in zero values. Clients relying on event timestamps for ordering would see incorrect data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address PR review feedback round 3 - Sanitize autopsy filename to prevent path traversal (reject non-base filenames) - Handle json.MarshalIndent error instead of discarding - Guard arrow-key navigation in CommandPalette when filtered list is empty - Prevent infinite retry loop on quest detail page fetch failure - Handle unknown phase in phaseStatus (return pending instead of marking all done) - Normalize backslashes in branch label path splitting - Self-host Google Fonts to eliminate CDN dependency (embedded binary model) - Rebuild dashboard static assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address local CodeRabbit review findings - Fix CommandPalette submitInput logic bug: success-path code was outside try block, running even after failures - Fix $derived(() => ...) to $derived.by() in CommandPalette grouped - Add Content-Type: application/json headers to all JSON responses - Clean up orphaned tmp file on config rename failure - Sanitize internal error details in HTTP responses - Add double-call guards for startPolling/startHeraldPolling to prevent subscription leaks - Add dataLoaded guard to timeline to prevent redundant tome fetching - Guard against null/undefined tags in autopsies search filter - Fix quest detail page race condition: set dataLoaded after fetch completes, add loading guard to prevent concurrent fetches - Rebuild dashboard static assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add local artifacts to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address PR review feedback round 6 - Define zombie classification in rekindle recovery flow - Fix <main_repo> → <repo_root> placeholder in SKILL.md - Handle UserHomeDir failure gracefully in config read - Add all 8 herald tiding types to filter UI - Guard NaN dates in GanttChart timeline - Guard arrow-key navigation when command palette is empty - Fallback for branch label when path split yields empty Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: port dashboard data/commands to SQLite after rebase - Add gitRoot field back to Server for file-based operations (command queue, config read/write) not yet migrated to SQLite - Port autopsy handler to use autopsy.Scan instead of removed file-based LoadByID/List functions - Port tome handler to use tome.Load with SQLite connection - Add Timestamp field to WSEvent broadcasts (from branch) - Update NewServer callers with gitRoot parameter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove accidentally committed local files * fix(docs): use pinned CLI path in rekindle.md and fellowship SKILL.md Replace bare `fellowship` CLI references with the pinned path `~/.claude/fellowship/bin/fellowship` per the documented convention. Addresses CodeRabbit review comments from PR #76. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(dashboard): add Adversarial to phase timeline and fix eagles API parsing - Add Adversarial phase to PhaseTimeline.svelte phases array so progress bar renders correctly during adversarial review - Fix quests.ts to extract .quests array from eagles API response object instead of treating the whole response as the array Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(dashboard): address frontend error handling CodeRabbit review comments - Await res.json() in all read helpers so JSON parse errors are caught by surrounding try/catch instead of escaping as unhandled rejections - Add synchronous (non-reactive) submitting guard in GateActions to prevent duplicate approve/reject submissions on rapid double-click - Restructure QuestCard to render gate action buttons as siblings of the clickable card link rather than nested inside it, fixing interactive element nesting. Also guard against empty worktree prop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ard shortcut - Add `ws !== socket` guard to onmessage and onerror handlers in websocket.ts to prevent stale socket instances from mutating shared state during reconnection races - Add CONNECTING state check to prevent duplicate connection attempts - Normalize keyboard shortcut key check with toLowerCase() for cross-platform robustness (Caps Lock edge case) Addresses CodeRabbit review comments from PR #76. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…, quest navigation (#84) * fix(dashboard): complete stale socket guards and cross-platform keyboard shortcut - Add `ws !== socket` guard to onmessage and onerror handlers in websocket.ts to prevent stale socket instances from mutating shared state during reconnection races - Add CONNECTING state check to prevent duplicate connection attempts - Normalize keyboard shortcut key check with toLowerCase() for cross-platform robustness (Caps Lock edge case) Addresses CodeRabbit review comments from PR #76. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address balrog findings — quest navigation reset and onerror cleanup - Reset dataLoaded/loadFailed state when questName changes to prevent stale data when navigating between quest detail pages - Revert onerror stale guard — errored sockets should always be closed for cleanup; the onclose handler already guards shared state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(dashboard): address CodeRabbit review — robustness and concurrency - queue.go: handle rand.Read error, use unique tmp path per PID, add file locking (flock) for cross-process safety - data.go: handle UserHomeDir error on config write path - server.go: NewServer returns error instead of log.Fatalf - ws.go: snapshot conns under RLock before writing to avoid holding mutex during network I/O; fix misleading CheckOrigin comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address balrog findings — per-conn write mutex, idempotent close - ws.go: introduce wsConn wrapper with per-connection write mutex (gorilla/websocket requires serialized writes) and sync.Once Close to prevent double-close on concurrent Remove + Broadcast failure - data.go: move error check before suffix filter in handleAutopsies to avoid masking DB errors as 404 - data.go: use PID-unique tmp path in handleConfigWrite, consistent with queue.go SaveCommandQueue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(dashboard): add error logging to SQLite database Add a dedicated dashboard_errors table to record server-level errors (API handler failures, WebSocket errors) to the SQLite database instead of only writing to stderr. This enables the dashboard frontend to surface errors to the user. Backend changes: - Add dashboard_errors table to schema with timestamp, source, handler, message, and detail columns - Add LogError/ReadErrors/ClearErrors functions in errlog.go - Add GET /api/errors and DELETE /api/errors endpoints - Add Server.logError() best-effort helper that also broadcasts "error-logged" WS events - Instrument all 500-error paths in server.go, ws.go, and data.go - Add logFunc callback to Hub for WebSocket error logging Frontend changes: - Add DashboardError type to types.ts - Add errors store with WS-triggered refetch on "error-logged" events - Add ErrorBanner component with expandable error list and clear button - Integrate ErrorBanner into Shell component - Add "error-logged" to WSEventType union - Rebuild static assets Tests: - TestLogErrorAndReadErrors: verify insert and read ordering - TestAPIErrors: verify GET /api/errors returns logged errors - TestAPIClearErrors: verify DELETE /api/errors clears all errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(dashboard): address adversarial review findings - Prevent infinite recursion in Broadcast by skipping logFunc for "error-logged" events (logError -> Broadcast -> marshal fail -> logFunc -> logError cycle) - Add auto-pruning to LogError: keep only the most recent 500 entries to prevent unbounded table growth - Add comment explaining why handleErrors/handleClearErrors don't self-log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Remove dead error check in handleAutopsies and add logError to the reachable error path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 11
♻️ Duplicate comments (2)
cli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.js (1)
1-1:⚠️ Potential issue | 🟠 MajorThe first config read can still trap the page in “Loading configuration...”.
The initial
fetchConfigpath only clears the loading flag on success. If that request rejects, the source page never leaves its loading placeholder. Please handle the failure indashboard/src/routes/config/+page.svelteand regenerate this bundle.cli/internal/dashboard/data.go (1)
141-147:⚠️ Potential issue | 🟠 MajorAbort on non-
ENOENTconfig read errors.If
os.ReadFile(configPath)fails for permissions or I/O,existingstays empty and the next save can replace the file with only the new key. Treat any read error other thanos.IsNotExist(err)as a hard failure.🛡️ Suggested fix
existing := make(map[string]interface{}) if data, err := os.ReadFile(configPath); err == nil { if err := json.Unmarshal(data, &existing); err != nil { http.Error(w, "existing config file contains invalid JSON", http.StatusInternalServerError) return } + } else if !os.IsNotExist(err) { + http.Error(w, "failed to read existing config", http.StatusInternalServerError) + return }
🧹 Nitpick comments (2)
dashboard/src/lib/stores/quests.ts (1)
51-69: Coalesce refreshes triggered by the same websocket burst.One gate approval already emits both
gate-resolvedandquest-changed, so this subscriber can kick off two identicalrefreshAll()runs for one user action. Because each run fans out into three fetches, that turns into six requests and interleaved store updates.♻️ One way to collapse duplicate refreshes
let unsubscribe: (() => void) | null = null; +let refreshInFlight: Promise<void> | null = null; + +function scheduleRefresh() { + if (refreshInFlight) return refreshInFlight; + refreshInFlight = refreshAll().finally(() => { + refreshInFlight = null; + }); + return refreshInFlight; +} export function startPolling() { if (unsubscribe) return; - refreshAll(); + void scheduleRefresh(); unsubscribe = lastEvent.subscribe((event) => { if (!event) return; switch (event.type) { case 'quest-changed': case 'gate-submitted': case 'gate-resolved': case 'command-completed': - refreshAll(); + void scheduleRefresh(); break; case 'alert': fetchProblems();dashboard/src/lib/components/QuestCard.svelte (1)
15-16: Prefer a real anchor overdiv role="link".Now that
GateActionslives outside the clickable area, using a plain<a href>restores open-in-new-tab/copy-link behavior and lets you drop the accessibility ignore plus the manual key handling.🔗 Minimal cleanup
- import { goto } from '$app/navigation'; import PhaseTimeline from './PhaseTimeline.svelte'; import GateActions from './GateActions.svelte'; import type { QuestStatus, QuestHealth } from '$lib/types'; let { quest, health }: { quest: QuestStatus; health?: QuestHealth } = $props(); - - function navigateToQuest() { - goto(`/quest/${encodeURIComponent(quest.name)}`); - } </script> <div class="quest-card-wrapper" class:gate-pending={quest.gate_pending}> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div class="quest-card" onclick={navigateToQuest} onkeydown={(e) => e.key === 'Enter' && navigateToQuest()} role="link" tabindex="0"> + <a class="quest-card" href={`/quest/${encodeURIComponent(quest.name)}`}> @@ - </div> + </a>
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 428fab33-e30c-4835-b782-01d49f585997
📒 Files selected for processing (32)
cli/cmd/fellowship/main.gocli/internal/dashboard/data.gocli/internal/dashboard/queue.gocli/internal/dashboard/server.gocli/internal/dashboard/server_test.gocli/internal/dashboard/static/_app/immutable/chunks/BEMN3nqi.jscli/internal/dashboard/static/_app/immutable/chunks/BX8RXYyp.jscli/internal/dashboard/static/_app/immutable/chunks/CXSl1Cmv.jscli/internal/dashboard/static/_app/immutable/chunks/DfmEkXBB.jscli/internal/dashboard/static/_app/immutable/entry/app.COsyL2J0.jscli/internal/dashboard/static/_app/immutable/entry/start.Bhmut7i0.jscli/internal/dashboard/static/_app/immutable/nodes/0.Bo2aciyg.jscli/internal/dashboard/static/_app/immutable/nodes/1.CkYkQUGd.jscli/internal/dashboard/static/_app/immutable/nodes/4.BpuSHdkg.jscli/internal/dashboard/static/_app/immutable/nodes/5.DjFqZ3VZ.jscli/internal/dashboard/static/_app/immutable/nodes/6.GopVlA_i.jscli/internal/dashboard/static/_app/immutable/nodes/7.CfJplPK4.jscli/internal/dashboard/static/_app/immutable/nodes/8.DH6zydf3.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/index.htmlcli/internal/dashboard/ws.godashboard/src/lib/api.tsdashboard/src/lib/components/GateActions.sveltedashboard/src/lib/components/PhaseTimeline.sveltedashboard/src/lib/components/QuestCard.sveltedashboard/src/lib/components/Shell.sveltedashboard/src/lib/stores/quests.tsdashboard/src/lib/stores/websocket.tsdashboard/src/routes/quest/[id]/+page.svelteplugin/commands/rekindle.mdplugin/skills/fellowship/SKILL.mdsite/src/lib/styles/theme.css
✅ Files skipped from review due to trivial changes (6)
- cli/internal/dashboard/static/_app/immutable/entry/start.Bhmut7i0.js
- cli/internal/dashboard/static/_app/immutable/chunks/CXSl1Cmv.js
- cli/internal/dashboard/static/index.html
- plugin/commands/rekindle.md
- dashboard/src/routes/quest/[id]/+page.svelte
- dashboard/src/lib/api.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- cli/internal/dashboard/static/_app/version.json
- plugin/skills/fellowship/SKILL.md
- cli/internal/dashboard/server_test.go
- dashboard/src/lib/components/PhaseTimeline.svelte
- dashboard/src/lib/components/Shell.svelte
- dashboard/src/lib/stores/websocket.ts
- cli/internal/dashboard/queue.go
| if err != nil { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| json.NewEncoder(w).Encode([]interface{}{}) | ||
| return | ||
| } |
There was a problem hiding this comment.
Don't collapse database failures into empty success responses.
These branches turn read failures into 200 responses with empty/default JSON, so the dashboard can't tell “no data yet” from “storage is broken.” Return 500 when the DB call fails, and only synthesize empty payloads for true not-found cases.
Also applies to: 69-77
| case "global": | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| http.Error(w, "unable to determine home directory", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| configPath = filepath.Join(home, ".claude", "fellowship.json") |
There was a problem hiding this comment.
Keep the global config private on first write.
For global scope this creates ~/.claude with 0755 and the temp file with 0644. On a fresh setup, the first save makes the directory listable and the config world-readable. Use tighter defaults for user-scoped config, or preserve existing modes.
Also applies to: 151-161
| // Try to serve static file first | ||
| path := strings.TrimPrefix(r.URL.Path, "/") | ||
| if path == "" { | ||
| path = "index.html" | ||
| } | ||
| if f, err := staticFS.Open(path); err == nil { | ||
| stat, statErr := f.Stat() | ||
| f.Close() | ||
| if statErr == nil && !stat.IsDir() { | ||
| fileServer.ServeHTTP(w, r) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // SPA fallback: serve index.html for client-side routes | ||
| data, _ := staticFiles.ReadFile("static/index.html") | ||
| w.Header().Set("Content-Type", "text/html") | ||
| if data == nil { | ||
| http.NotFound(w, r) | ||
| return | ||
| } | ||
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| w.Write(data) |
There was a problem hiding this comment.
Missing assets should 404, not fall back to the SPA shell.
If a hashed JS/CSS/image request misses here, the server returns index.html with 200 OK, and the browser ends up parsing HTML as an asset. Restrict the fallback to route navigations, and return 404 for missing static files.
| now := time.Now().UTC() | ||
| ts := now.Unix() | ||
| s.hub.Broadcast(WSEvent{Type: "gate-resolved", QuestID: result.Name, Action: "approved", Timestamp: ts}) | ||
| s.hub.Broadcast(WSEvent{Type: "quest-changed", QuestID: result.Name, Timestamp: ts}) | ||
|
|
||
| // Best-effort herald announcements after tx commits. | ||
| s.db.WithConn(context.Background(), func(conn *db.Conn) error { | ||
| now := time.Now().UTC().Format(time.RFC3339) | ||
| nowStr := now.Format(time.RFC3339) | ||
| herald.Announce(conn, herald.Tiding{ | ||
| Timestamp: now, Quest: result.Name, Type: herald.GateApproved, | ||
| Timestamp: nowStr, Quest: result.Name, Type: herald.GateApproved, | ||
| Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), | ||
| }) | ||
| herald.Announce(conn, herald.Tiding{ | ||
| Timestamp: now, Quest: result.Name, Type: herald.PhaseTransition, | ||
| Timestamp: nowStr, Quest: result.Name, Type: herald.PhaseTransition, | ||
| Phase: result.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, result.Phase), |
There was a problem hiding this comment.
Keep herald writes in the same transaction as gate state changes.
These handlers commit the quest mutation first and then append herald tidings on a separate connection. If that second step fails, the state change is durable but the activity log/feed misses the corresponding event. batchApproveCompany already keeps this atomic; approve/reject should do the same.
Based on learnings, herald.Announce calls are intentionally kept inside the same SQLite transaction as the state mutation because the herald log participates in the same atomic DB change.
Also applies to: 272-280
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{p as me,_ as C,E as H,$ as ye,b as he,F as _,s as g,d as r,l as e,g as o,t as b,a0 as F}from"../chunks/CFIQ5hjj.js";import{d as we,a as p,f as d,s as w,b as I,e as ke}from"../chunks/ByVGy8qs.js";import{i as E}from"../chunks/537vdszY.js";import{e as Q,i as U}from"../chunks/zKsXIgWY.js";import{r as xe,s as je}from"../chunks/DdAmTr_Q.js";import{s as X}from"../chunks/CQvy7xpd.js";import{i as Se,s as Oe}from"../chunks/CaohEQ9u.js";import{a as Ce,s as Ee}from"../chunks/C67DyfnJ.js";var Pe=d('<div class="empty-state svelte-1gp6n77">Loading configuration...</div>'),Fe=d("<button> </button>"),Ge=d('<input type="text" class="setting-input svelte-1gp6n77"/>'),Ke=d('<option class="svelte-1gp6n77"> </option>'),Ne=d('<select class="setting-select svelte-1gp6n77"></select>'),Te=d("<span> </span>"),Ae=d('<div class="setting-row svelte-1gp6n77"><div class="setting-info svelte-1gp6n77"><span class="setting-label svelte-1gp6n77"> </span> <span class="setting-scope svelte-1gp6n77"> </span></div> <div class="setting-control svelte-1gp6n77"><!> <!></div></div>'),Je=d('<div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Global Config (raw)</div> <pre class="raw-json svelte-1gp6n77"> </pre></div>'),Me=d('<div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Project Config (raw)</div> <pre class="raw-json svelte-1gp6n77"> </pre></div>'),Be=d('<div class="config-sections svelte-1gp6n77"><div class="config-section svelte-1gp6n77"><div class="section-header svelte-1gp6n77">Settings</div> <div class="settings-list svelte-1gp6n77"></div></div> <!> <!></div>'),De=d('<div class="config-view svelte-1gp6n77"><div class="view-header svelte-1gp6n77"><h1 class="svelte-1gp6n77">Config</h1></div> <!></div>');function Ue(Y,Z){me(Z,!0);let k=C(H({})),m=C(H({})),M=C(!0),j=C(null),y=C(null);const G=[{key:"gate_auto_approve",label:"Gate Auto-Approve",type:"boolean"},{key:"branch_prefix",label:"Branch Prefix",type:"text"},{key:"pr_draft",label:"PR Draft Mode",type:"boolean"},{key:"palantir_enabled",label:"Palantir Enabled",type:"boolean"},{key:"worktree_strategy",label:"Worktree Strategy",type:"select",options:["auto","manual"]}];function B(t){if(t in e(m))return e(m)[t];if(t in e(k))return e(k)[t]}function P(t){return t in e(m)?"project":"global"}function D(){const t=new Set(G.map(n=>n.key));return Object.keys(e(k)).filter(n=>!t.has(n))}function L(){const t=new Set(G.map(n=>n.key));return Object.keys(e(m)).filter(n=>!t.has(n))}async function K(t,n,v){_(j,t,!0);try{await Ee(t,n,v),v==="project"?e(m)[t]=n:e(k)[t]=n,_(y,{key:t,ok:!0},!0)}catch{_(y,{key:t,ok:!1},!0)}_(j,null),setTimeout(()=>{e(y)?.key===t&&_(y,null)},2e3)}async function $(t){const n=B(t),v=P(t);await K(t,!n,v)}async function ee(t,n){const v=n.target,x=P(t);await K(t,v.value,x)}async function te(t,n){const v=n.target,x=P(t);await K(t,v.value,x)}ye(async()=>{const t=await Ce();_(k,t.global??{},!0),_(m,t.project??{},!0),_(M,!1)});var N=De(),ae=g(r(N),2);{var se=t=>{var n=Pe();p(t,n)},ne=t=>{var n=Be(),v=r(n),x=g(r(v),2);Q(x,21,()=>G,U,(f,s)=>{const c=F(()=>B(e(s).key)),S=F(()=>P(e(s).key));var l=Ae(),T=r(l),A=r(T),ce=r(A,!0);o(A);var V=g(A,2),pe=r(V,!0);o(V),o(T);var W=g(T,2),q=r(W);{var de=i=>{var a=Fe();let u;var h=r(a,!0);o(a),b(()=>{u=X(a,1,"toggle-btn svelte-1gp6n77",null,u,{active:!!e(c)}),a.disabled=e(j)===e(s).key,w(h,e(c)?"ON":"OFF")}),I("click",a,()=>$(e(s).key)),p(i,a)},ue=i=>{var a=Ge();xe(a),b(()=>{je(a,e(c)??""),a.disabled=e(j)===e(s).key}),ke("blur",a,u=>ee(e(s).key,u)),p(i,a)},fe=i=>{var a=Ne();Q(a,21,()=>e(s).options??[],U,(h,J)=>{var O=Ke(),be=r(O,!0);o(O);var z={};b(()=>{w(be,e(J)),z!==(z=e(J))&&(O.value=(O.__value=e(J))??"")}),p(h,O)}),o(a);var u;Se(a),b(()=>{a.disabled=e(j)===e(s).key,u!==(u=e(c)??e(s).options?.[0])&&(a.value=(a.__value=e(c)??e(s).options?.[0])??"",Oe(a,e(c)??e(s).options?.[0]))}),I("change",a,h=>te(e(s).key,h)),p(i,a)};E(q,i=>{e(s).type==="boolean"?i(de):e(s).type==="text"?i(ue,1):e(s).type==="select"&&i(fe,2)})}var ge=g(q,2);{var _e=i=>{var a=Te();let u;var h=r(a,!0);o(a),b(()=>{u=X(a,1,"save-indicator svelte-1gp6n77",null,u,{error:!e(y).ok}),w(h,e(y).ok?"Saved":"Error")}),p(i,a)};E(ge,i=>{e(y)?.key===e(s).key&&i(_e)})}o(W),o(l),b(()=>{w(ce,e(s).label),w(pe,e(S))}),p(f,l)}),o(x),o(v);var R=g(v,2);{var oe=f=>{var s=Je(),c=g(r(s),2),S=r(c,!0);o(c),o(s),b(l=>w(S,l),[()=>JSON.stringify(Object.fromEntries(D().map(l=>[l,e(k)[l]])),null,2)]),p(f,s)},re=F(()=>D().length>0);E(R,f=>{e(re)&&f(oe)})}var le=g(R,2);{var ie=f=>{var s=Me(),c=g(r(s),2),S=r(c,!0);o(c),o(s),b(l=>w(S,l),[()=>JSON.stringify(Object.fromEntries(L().map(l=>[l,e(m)[l]])),null,2)]),p(f,s)},ve=F(()=>L().length>0);E(le,f=>{e(ve)&&f(ie)})}o(n),p(t,n)};E(ae,t=>{e(M)?t(se):t(ne,-1)})}o(N),p(Y,N),he()}we(["click","change"]);export{Ue as component}; | |||
There was a problem hiding this comment.
The settings controls still have no programmatic labels.
Each toggle, text input, and select is rendered next to label text, but none of the controls are actually associated with that text. Screen readers will not announce which setting is being edited, and the boolean toggle also lacks pressed-state semantics. Please add label/for or aria-labelledby, plus aria-pressed for the toggle, in dashboard/src/routes/config/+page.svelte.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{d as a,l as e,g as t,s as r,t as x,p as Ce,i as Ge,F as y,b as Oe,a0 as M,_ as N,f as fe}from"../chunks/CFIQ5hjj.js";import{s as Ie,a as le}from"../chunks/DLS4WeON.js";import{s as p,a as o,t as he,f as c,d as Ne,b as D}from"../chunks/ByVGy8qs.js";import{i as H}from"../chunks/537vdszY.js";import{e as Q,i as re}from"../chunks/zKsXIgWY.js";import{a as Pe}from"../chunks/DdAmTr_Q.js";import{s as b}from"../chunks/CQvy7xpd.js";import{p as We}from"../chunks/CXSl1Cmv.js";import{e as Ae,q as Be}from"../chunks/BX8RXYyp.js";import{c as De,d as Me}from"../chunks/C67DyfnJ.js";import{G as Qe}from"../chunks/CMrUtYuQ.js";import{H as Ue,t as je}from"../chunks/BEMN3nqi.js";var ze=c('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),Je=c('<div class="errand-list svelte-6y5tqw"></div>');function Ke(U,j){var P=Je();Q(P,21,()=>j.errands,W=>W.id,(W,S)=>{var E=ze(),O=a(E),F=a(O);{var v=m=>{var i=he("✓");o(m,i)},k=m=>{var i=he("●");o(m,i)};H(F,m=>{e(S).status==="done"?m(v):e(S).status==="active"&&m(k,1)})}t(O);var A=r(O,2),q=a(A,!0);t(A),t(E),x(()=>{b(O,1,`errand-check ${e(S).status??""}`,"svelte-6y5tqw"),b(A,1,`errand-text ${e(S).status??""}`,"svelte-6y5tqw"),p(q,e(S).description)}),o(W,E)}),t(P),o(U,P)}var Ve=c('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Xe=c('<div><span class="phase-step-label"> </span></div>'),Ye=c('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Ze=c('<span class="tab-badge svelte-1ysin3l"> </span>'),es=c('<div class="file-item svelte-1ysin3l"> </div>'),ss=c('<div class="files-list svelte-1ysin3l"></div>'),ts=c('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),as=c('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),ls=c('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),rs=c('<p class="empty svelte-1ysin3l">No data available</p>'),is=c('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),vs=c('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ns=c('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ws(U,j){Ce(j,!0);const P=()=>le(Ae,"$dashboardStatus",E),W=()=>le(Be,"$questHealths",E),S=()=>le(je,"$tidings",E),[E,O]=Ie();let F=M(()=>decodeURIComponent(We.params.id)),v=M(()=>P()?.quests.find(s=>s.name===e(F))),k=M(()=>W().find(s=>s.name===e(F))),A=M(()=>S().filter(s=>s.quest===e(F))),q=N(null),m=N(null),i=N("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let ie=N(!1),ve=N(!1),J=N(!1);Ge(()=>{if(e(v)&&!e(ie)&&!e(ve)&&!e(J)){y(J,!0);const s=e(v).worktree;Promise.all([De(s),Me(e(F))]).then(([l,n])=>{y(q,l,!0),y(m,n,!0),y(ie,!0)}).catch(()=>{y(ve,!0)}).finally(()=>{y(J,!1)})}});function ye(s){if(!e(v))return"pending";const l=z.indexOf(e(v).phase);if(l===-1)return"pending";const n=z.indexOf(s);return n<l?"done":n===l?"current":"pending"}var K=ns(),V=a(K),X=a(V),ne=r(a(X),4),be=a(ne,!0);t(ne),t(X);var ge=r(X,2);{var xe=s=>{var l=Ye(),n=fe(l),d=a(n),_=a(d,!0);t(d);var f=r(d,2),g=a(f,!0);t(f);var $=r(f,2);{var L=h=>{Qe(h,{get worktree(){return e(v).worktree}})};H($,h=>{e(v).gate_pending&&h(L)})}var R=r($,2);{var T=h=>{var C=Ve(),u=fe(C),w=r(u,2),G=a(w,!0);t(w),x(()=>{b(u,1,`health-dot ${e(k).health??""}`,"svelte-1ysin3l"),p(G,e(k).health)}),o(h,C)};H(R,h=>{e(k)&&h(T)})}t(n);var I=r(n,2);Q(I,21,()=>z,re,(h,C)=>{var u=Xe(),w=a(u),G=a(w,!0);t(w),t(u),x(ae=>{b(u,1,`phase-step ${ae??""}`,"svelte-1ysin3l"),p(G,e(C))},[()=>ye(e(C))]),o(h,u)}),t(I),x(()=>{p(_,e(v).name),p(g,e(v).phase)}),o(s,l)};H(ge,s=>{e(v)&&s(xe)})}t(V);var Y=r(V,2),B=a(Y);let oe;var we=r(a(B));{var ke=s=>{var l=Ze(),n=a(l);t(l),x(d=>p(n,`${d??""}/${e(q).items.length??""}`),[()=>e(q).items.filter(d=>d.status==="done").length]),o(s,l)};H(we,s=>{e(q)&&s(ke)})}t(B);var Z=r(B,2);let de;var ee=r(Z,2);let pe;var se=r(ee,2);let ce;var _e=r(se,2);let ue;t(Y);var me=r(Y,2),te=a(me),qe=a(te);{var $e=s=>{Ke(s,{get errands(){return e(q).items}})},He=s=>{var l=ss();Q(l,21,()=>e(m).files_touched,re,(n,d)=>{var _=es(),f=a(_,!0);t(_),x(()=>p(f,e(d))),o(n,_)}),t(l),o(s,l)},Se=s=>{var l=as(),n=r(a(l),2);Q(n,17,()=>e(m).gate_history,re,(d,_)=>{var f=ts(),g=a(f),$=a(g,!0);t(g);var L=r(g,2),R=a(L,!0);t(L);var T=r(L,2),I=a(T,!0);t(T),t(f),x(()=>{b(g,1,`tome-action ${e(_).action??""}`,"svelte-1ysin3l"),p($,e(_).action),p(R,e(_).phase),p(I,e(_).timestamp)}),o(d,f)}),t(l),o(s,l)},Ee=s=>{Ue(s,{get tidings(){return e(A)},limit:50})},Fe=s=>{var l=ls();o(s,l)},Le=s=>{var l=rs();o(s,l)};H(qe,s=>{e(i)==="errands"&&e(q)?s($e):e(i)==="files"&&e(m)?s(He,1):e(i)==="tome"&&e(m)?s(Se,2):e(i)==="herald"?s(Ee,3):e(i)==="logs"?s(Fe,4):s(Le,-1)})}t(te);var Re=r(te,2);{var Te=s=>{var l=vs(),n=a(l),d=r(a(n),2),_=r(a(d),2),f=a(_,!0);t(_),t(d);var g=r(d,2),$=r(a(g),2),L=a($,!0);t($),t(g);var R=r(g,2),T=r(a(R),2),I=a(T,!0);t(T),t(R);var h=r(R,2);{var C=u=>{var w=is(),G=r(a(w),2),ae=a(G,!0);t(G),t(w),x(()=>{b(G,1,`meta-value ${e(k).health??""}`,"svelte-1ysin3l"),p(ae,e(k).health)}),o(u,w)};H(h,u=>{e(k)&&u(C)})}t(n),t(l),x(u=>{p(f,u),Pe($,"title",e(v).worktree),p(L,e(v).worktree),p(I,e(v).status)},[()=>e(v).worktree.replace(/\\/g,"/").split("/").pop()||e(v).worktree]),o(s,l)};H(Re,s=>{e(v)&&s(Te)})}t(me),t(K),x(()=>{p(be,e(F)),oe=b(B,1,"tab svelte-1ysin3l",null,oe,{active:e(i)==="errands"}),de=b(Z,1,"tab svelte-1ysin3l",null,de,{active:e(i)==="files"}),pe=b(ee,1,"tab svelte-1ysin3l",null,pe,{active:e(i)==="tome"}),ce=b(se,1,"tab svelte-1ysin3l",null,ce,{active:e(i)==="herald"}),ue=b(_e,1,"tab svelte-1ysin3l",null,ue,{active:e(i)==="logs"})}),D("click",B,()=>y(i,"errands")),D("click",Z,()=>y(i,"files")),D("click",ee,()=>y(i,"tome")),D("click",se,()=>y(i,"herald")),D("click",_e,()=>y(i,"logs")),o(U,K),Oe(),O()}Ne(["click"]);export{ws as component}; | |||
There was a problem hiding this comment.
Re-fetch quest data when the quest id changes.
This page latches its local loaded/error guards after the first request and never resets them on same-route navigation. Moving from one /quest/[id] page to another can keep the previous quest’s errands/files/tome on screen, and an initial failure also blocks later retries until a hard refresh. Reset the local request state when the id changes, or key dashboard/src/routes/quest/[id]/+page.svelte by quest id before rebuilding.
The phase model has drifted between views.
This bundle’s stepper omits "Adversarial", but cli/internal/dashboard/static/_app/immutable/nodes/4.BpuSHdkg.js, Line 1 still includes it. When a quest enters that phase, the detail stepper falls back to its unknown-phase branch and marks every step as pending. Please source the phase order from one shared constant before rebuilding the compiled assets.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{p as F,f as Y,d as p,g as d,s as q,l as n,t as k,b as G,a0 as z,_ as y,F as x,E as A,$ as B,a1 as H}from"../chunks/CFIQ5hjj.js";import{s as J,a as K}from"../chunks/DLS4WeON.js";import{e as R,a as f,s as C,f as h}from"../chunks/ByVGy8qs.js";import{i as X}from"../chunks/537vdszY.js";import{d as L,s as Q,a as U,e as E,b as V,c as Z}from"../chunks/BX8RXYyp.js";import{d as tt}from"../chunks/C67DyfnJ.js";import{e as O,i as W}from"../chunks/zKsXIgWY.js";var et=h('<div class="gantt-bar svelte-t05lje"></div>'),at=h('<div class="gantt-row svelte-t05lje"><div class="gantt-label svelte-t05lje"> </div> <div class="gantt-track svelte-t05lje"></div></div>'),st=h('<div class="gantt-tooltip svelte-t05lje"><span class="tooltip-phase svelte-t05lje"> </span> <span class="tooltip-dur svelte-t05lje"> </span></div>'),rt=h('<div class="gantt-chart svelte-t05lje"></div> <!>',1);function nt(T,g){F(g,!0);const S={Onboard:"var(--accent-blue-dim)",Research:"var(--accent-purple-dim)",Plan:"var(--accent-gold-dim)",Implement:"var(--accent-green-dim)",Review:"var(--accent-gold)",Complete:"var(--accent-green)"},D={Onboard:"var(--accent-blue)",Research:"var(--accent-purple)",Plan:"var(--accent-gold)",Implement:"var(--accent-green-text)",Review:"var(--accent-gold)",Complete:"var(--accent-green-text)"};function P(a){const e=Math.floor(a/6e4);if(e<60)return`${e}m`;const s=Math.floor(e/60);return s<24?`${s}h ${e%60}m`:`${Math.floor(s/24)}d ${s%24}h`}let _=z(()=>{if(g.quests.length===0)return[];let a=1/0,e=-1/0;for(const i of g.quests){for(const r of i.phases_completed){const l=new Date(r.timestamp).getTime();isNaN(l)||(l<a&&(a=l),l>e&&(e=l))}const o=new Date(i.created_at).getTime();!isNaN(o)&&o<a&&(a=o)}e<=a&&(e=a+1);const s=e-a,t=[];for(const i of g.quests){const o=[...i.phases_completed].sort((c,u)=>new Date(c.timestamp).getTime()-new Date(u.timestamp).getTime());if(o.length===0)continue;const r=[],l=new Date(i.created_at).getTime();for(let c=0;c<o.length;c++){const u=c===0?l:new Date(o[c-1].timestamp).getTime(),I=new Date(o[c].timestamp).getTime()-u;r.push({phase:o[c].phase,startPct:(u-a)/s*100,widthPct:Math.max(I/s*100,.5),duration:P(I)})}t.push({name:i.quest_name,segments:r})}return t}),v=y(null);function w(a,e,s){x(v,{quest:e,phase:s.phase,duration:s.duration,x:a.clientX,y:a.clientY},!0)}function $(){x(v,null)}var b=rt(),m=Y(b);O(m,21,()=>n(_),W,(a,e)=>{var s=at(),t=p(s),i=p(t,!0);d(t);var o=q(t,2);O(o,21,()=>n(e).segments,W,(r,l)=>{var c=et();let u;k(()=>u=L(c,"",u,{left:`${n(l).startPct??""}%`,width:`${n(l).widthPct??""}%`,background:S[n(l).phase]??"var(--bg-raised)",color:D[n(l).phase]??"var(--text-muted)"})),R("mouseenter",c,N=>w(N,n(e).name,n(l))),R("mouseleave",c,$),f(r,c)}),d(o),d(s),k(()=>C(i,n(e).name)),f(a,s)}),d(m);var j=q(m,2);{var M=a=>{var e=st();let s;var t=p(e),i=p(t,!0);d(t);var o=q(t,2),r=p(o,!0);d(o),d(e),k(()=>{s=L(e,"",s,{left:`${n(v).x+12}px`,top:`${n(v).y-8}px`}),C(i,n(v).phase),C(r,n(v).duration)}),f(a,e)};X(j,a=>{n(v)&&a(M)})}f(T,b),G()}var ot=h('<div class="empty-state svelte-1fjec68">Loading quest timeline data...</div>'),it=h('<div class="empty-state svelte-1fjec68">No quest timeline data available.</div>'),lt=h('<div class="timeline-view svelte-1fjec68"><div class="view-header svelte-1fjec68"><h1 class="svelte-1fjec68">Timeline</h1></div> <div class="chart-area svelte-1fjec68"><!></div></div>');function gt(T,g){F(g,!0);const S=()=>K(E,"$dashboardStatus",D),[D,P]=J();let _=y(A([])),v=y(!0),w=y(!1);async function $(){const t=S();if(!t)return;x(w,!0);const i=t.quests.map(r=>r.name),o=await Promise.allSettled(i.map(r=>tt(r)));x(_,o.filter(r=>r.status==="fulfilled").map(r=>r.value).filter(r=>r!=null),!0),x(v,!1)}let b=null;B(()=>{Q(),U(),b=E.subscribe(t=>{t&&!n(w)&&$()})}),H(()=>{V(),Z(),b?.()});var m=lt(),j=q(p(m),2),M=p(j);{var a=t=>{var i=ot();f(t,i)},e=t=>{var i=it();f(t,i)},s=t=>{nt(t,{get quests(){return n(_)}})};X(M,t=>{n(v)?t(a):n(_).length===0?t(e,1):t(s,-1)})}d(j),d(m),f(T,m),G(),P()}export{gt as component}; | |||
There was a problem hiding this comment.
Release the in-flight guard after each timeline refresh.
The Timeline load path sets its reload guard before fetching tome data, but never clears it afterward. After the first batch resolves, later dashboard-status updates can no longer trigger another refresh, so this view goes stale until a full navigation. Please wrap the source load in try/finally in dashboard/src/routes/timeline/+page.svelte and regenerate this bundle.
| var upgrader = websocket.Upgrader{ | ||
| // Allow all origins — the dashboard binds to localhost but may be accessed | ||
| // from different ports or via forwarded connections during development. | ||
| CheckOrigin: func(r *http.Request) bool { return true }, |
There was a problem hiding this comment.
Restrict WebSocket origins instead of accepting every browser origin.
CheckOrigin: return true lets any site connect to the local dashboard socket and read event traffic from the user's machine. Allow loopback/same-host origins, and make any forwarded hosts explicit rather than blanket-allowing all origins.
🔒 Suggested restriction
import (
"encoding/json"
"log"
+ "net/url"
"net/http"
"sync"
"time"
@@
var upgrader = websocket.Upgrader{
// Allow all origins — the dashboard binds to localhost but may be accessed
// from different ports or via forwarded connections during development.
- CheckOrigin: func(r *http.Request) bool { return true },
+ CheckOrigin: func(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+ switch u.Hostname() {
+ case "localhost", "127.0.0.1", "::1":
+ return true
+ default:
+ return false
+ }
+ },
}- Add command queue pruning (max 200) to prevent unbounded growth - Add mutex to handleConfigWrite to prevent concurrent write races - Set WebSocket read limit (4096) to prevent unbounded memory allocation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move configMu from package-level var to Server struct field for consistency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add lifecycle hooks to quest detail page for direct navigation support - Add loading state for quest detail when data not yet available - Validate config write key is non-empty Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap fetchConfig in try/catch so loading state clears even on network failure, preventing the page from being stuck on "Loading configuration..." Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Abort config write on non-ENOENT read errors to prevent data loss - Coalesce WS-triggered refreshes to avoid duplicate API calls - Use semantic <a> element for QuestCard navigation instead of div role="link" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 14
♻️ Duplicate comments (2)
cli/internal/dashboard/ws.go (1)
25-28:⚠️ Potential issue | 🟠 MajorRestrict browser origins for the dashboard socket.
CheckOrigin: return truelets any site open this localhost socket and consume dashboard events from the user's machine. Allow loopback/same-host origins by default, and make any forwarded hosts explicit instead of blanket-allowing all origins.🔒 Suggested restriction
import ( "encoding/json" "log" + "net/url" "net/http" "sync" "time" @@ var upgrader = websocket.Upgrader{ // Allow all origins — the dashboard binds to localhost but may be accessed // from different ports or via forwarded connections during development. - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + switch u.Hostname() { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } + }, }For Gorilla WebSocket v1.5.3, what does the `Upgrader.CheckOrigin` documentation recommend, and what are the security implications of always returning `true`?cli/internal/dashboard/data.go (1)
135-175:⚠️ Potential issue | 🟠 MajorKeep the first global config write private.
For
req.Scope == "global", this path creates~/.claudewith0755and writesfellowship.jsonwith0644. Any future secret-bearing keys become world-readable/listable on a fresh setup. Use tighter modes for the global path, or preserve the existing ones.🔐 Suggested tightening
- if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + dirMode := os.FileMode(0755) + fileMode := os.FileMode(0644) + if req.Scope == "global" { + dirMode = 0700 + fileMode = 0600 + } + if err := os.MkdirAll(filepath.Dir(configPath), dirMode); err != nil { http.Error(w, "failed to create config directory", http.StatusInternalServerError) return } @@ - if err := os.WriteFile(tmp, data, 0644); err != nil { + if err := os.WriteFile(tmp, data, fileMode); err != nil { http.Error(w, "failed to write config", http.StatusInternalServerError) return }
🧹 Nitpick comments (3)
cli/internal/dashboard/static/_app/immutable/chunks/DM7OFrYY.js (1)
1-1: Don’t silently swallow/api/heraldfetch failures.On Line 1,
catch {}drops all failures with no signal. This makes production diagnosis harder when herald data stops updating.🛠️ Proposed fix (source-level)
-} catch {} +} catch (err) { + console.error('Failed to fetch /api/herald', err); +}cli/internal/dashboard/static/_app/immutable/chunks/4mhR39rs.js (1)
1-1: Coalesce websocket-triggered refreshes.Every matching event calls
p()immediately, so bursts ofquest-changed/command-completedmessages can spin up overlapping/api/status,/api/eagles, and/api/herald/problemsfetches. Because each response writes straight into the stores, an older slower refresh can still land last and repaint stale state.dashboard/src/lib/components/ErrorBanner.svelte (1)
33-40: Expose the disclosure state on the summary button.This control expands and collapses content, but it never announces whether the list is open or closed. Adding
aria-expandedandaria-controlshere makes the banner understandable to screen-reader users.♿ Suggested markup tweak
- <button class="error-summary" onclick={() => (expanded = !expanded)}> + <button + class="error-summary" + type="button" + aria-expanded={expanded} + aria-controls="dashboard-error-list" + onclick={() => (expanded = !expanded)} + > <span class="error-icon">!</span> <span class="error-count">{errors.length} server error{errors.length !== 1 ? 's' : ''}</span> <span class="error-toggle">{expanded ? '▾' : '▸'}</span> </button> {`#if` expanded} - <div class="error-list"> + <div id="dashboard-error-list" class="error-list">
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 882f51fb-6eec-4682-866d-5d10c74a7b2c
⛔ Files ignored due to path filters (1)
.claude/scheduled_tasks.lockis excluded by!**/*.lock
📒 Files selected for processing (38)
.mcp.jsoncli/internal/dashboard/data.gocli/internal/dashboard/errlog.gocli/internal/dashboard/queue.gocli/internal/dashboard/server.gocli/internal/dashboard/server_test.gocli/internal/dashboard/static/_app/immutable/assets/0.DzBZsd8E.csscli/internal/dashboard/static/_app/immutable/assets/4.DQ8-Y4U1.csscli/internal/dashboard/static/_app/immutable/chunks/4mhR39rs.jscli/internal/dashboard/static/_app/immutable/chunks/C19yGuqu.jscli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.jscli/internal/dashboard/static/_app/immutable/chunks/DJzzQicO.jscli/internal/dashboard/static/_app/immutable/chunks/DM7OFrYY.jscli/internal/dashboard/static/_app/immutable/chunks/c59pmuMM.jscli/internal/dashboard/static/_app/immutable/chunks/eQSYeF-0.jscli/internal/dashboard/static/_app/immutable/entry/app.CbPicme5.jscli/internal/dashboard/static/_app/immutable/entry/start.COYkiJU8.jscli/internal/dashboard/static/_app/immutable/nodes/0.C133qmEE.jscli/internal/dashboard/static/_app/immutable/nodes/1.BwivCJmQ.jscli/internal/dashboard/static/_app/immutable/nodes/3.DUsoW_8B.jscli/internal/dashboard/static/_app/immutable/nodes/4.P1MrWDtB.jscli/internal/dashboard/static/_app/immutable/nodes/5.DCaVDi0b.jscli/internal/dashboard/static/_app/immutable/nodes/6.5lNWWaDI.jscli/internal/dashboard/static/_app/immutable/nodes/7.BQnCdFNo.jscli/internal/dashboard/static/_app/immutable/nodes/8.DqkmqCzQ.jscli/internal/dashboard/static/_app/version.jsoncli/internal/dashboard/static/index.htmlcli/internal/dashboard/ws.gocli/internal/db/schema.godashboard/src/lib/components/ErrorBanner.sveltedashboard/src/lib/components/QuestCard.sveltedashboard/src/lib/components/Shell.sveltedashboard/src/lib/stores/errors.tsdashboard/src/lib/stores/quests.tsdashboard/src/lib/stores/websocket.tsdashboard/src/lib/types.tsdashboard/src/routes/config/+page.sveltedashboard/src/routes/quest/[id]/+page.svelte
✅ Files skipped from review due to trivial changes (6)
- cli/internal/dashboard/static/_app/immutable/assets/4.DQ8-Y4U1.css
- .mcp.json
- cli/internal/dashboard/static/_app/immutable/entry/start.COYkiJU8.js
- cli/internal/dashboard/static/_app/immutable/nodes/1.BwivCJmQ.js
- cli/internal/dashboard/static/_app/immutable/assets/0.DzBZsd8E.css
- cli/internal/dashboard/static/_app/immutable/chunks/eQSYeF-0.js
🚧 Files skipped from review as they are similar to previous changes (8)
- cli/internal/dashboard/static/_app/version.json
- dashboard/src/lib/components/QuestCard.svelte
- dashboard/src/lib/components/Shell.svelte
- dashboard/src/lib/stores/websocket.ts
- cli/internal/dashboard/static/index.html
- dashboard/src/routes/quest/[id]/+page.svelte
- cli/internal/dashboard/server.go
- dashboard/src/lib/types.ts
| // Individual autopsy lookup not supported via SQLite API; scan all and filter. | ||
| var records []autopsy.Autopsy | ||
| err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { | ||
| var loadErr error | ||
| records, loadErr = autopsy.Scan(conn, autopsy.ScanOptions{}, autopsy.DefaultExpiryDays) | ||
| return loadErr | ||
| }) |
There was a problem hiding this comment.
handleAutopsies calls a filter-only API with no filters.
cli/internal/autopsy/autopsy.go:134-136 rejects autopsy.ScanOptions{} when files/modules/tags are all empty, but this handler always passes an empty options struct. That means /api/autopsies falls straight into the error branch and the new Autopsies view cannot load real data.
| @@ -0,0 +1 @@ | |||
| import"./DsnmJJEf.js";import{p as g,d as o,g as f,s as x,t as h,l as r,b as y,_,F as l}from"./CFIQ5hjj.js";import{d as k,s as c,b as u,a as j,f as w}from"./ByVGy8qs.js";import{b as G,r as A}from"./c59pmuMM.js";var F=w('<div class="gate-actions svelte-1fnxfh2"><button class="gate-btn approve svelte-1fnxfh2"> </button> <button class="gate-btn reject svelte-1fnxfh2"> </button></div>');function C(p,i){g(i,!0);let t=_(!1),e=!1;async function v(){if(!e){e=!0,l(t,!0);try{await G(i.worktree)}finally{e=!1,l(t,!1)}}}async function b(){if(!e){e=!0,l(t,!0);try{await A(i.worktree)}finally{e=!1,l(t,!1)}}}var n=F(),a=o(n),d=o(a,!0);f(a);var s=x(a,2),m=o(s,!0);f(s),f(n),h(()=>{a.disabled=r(t),c(d,r(t)?"...":"Approve"),s.disabled=r(t),c(m,r(t)?"...":"Reject")}),u("click",a,v),u("click",s,b),j(p,n),y()}k(["click"]);export{C as G}; | |||
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== GateActions handlers (check for try/finally without catch) =="
sed -n '1,140p' dashboard/src/lib/components/GateActions.svelte
echo
echo "== API methods (check thrown errors on non-OK responses) =="
sed -n '1,120p' dashboard/src/lib/api.ts
echo
echo "== Focused matches =="
rg -n 'async function (approve|reject)|await (approveGate|rejectGate)\(|catch\s*\(' dashboard/src/lib/components/GateActions.svelte
rg -n 'if \(!res\.ok\) throw new Error' dashboard/src/lib/api.tsRepository: justinjdev/fellowship
Length of output: 5768
Add error handling to gate action handlers to prevent unhandled promise rejections.
The approve() and reject() functions in dashboard/src/lib/components/GateActions.svelte await approveGate() and rejectGate() calls within try/finally blocks, but lack catch handlers. Since both API functions throw errors on non-OK responses, failed requests will reject without user-facing feedback or error logging.
Suggested fix
async function approve() {
if (submitting) return;
submitting = true;
loading = true;
+ actionError = null;
try {
await approveGate(worktree);
+ } catch (err) {
+ actionError = err instanceof Error ? err.message : 'Gate approve failed';
} finally {
submitting = false;
loading = false;
}
}
async function reject() {
if (submitting) return;
submitting = true;
loading = true;
+ actionError = null;
try {
await rejectGate(worktree);
+ } catch (err) {
+ actionError = err instanceof Error ? err.message : 'Gate reject failed';
} finally {
submitting = false;
loading = false;
}
}Add let actionError = $state<string | null>(null); to component state and display in UI.
| @@ -0,0 +1 @@ | |||
| import{w as r}from"./Dv0Hwark.js";const c=r(!1),i=r(null);let e=null,o=1e3;const f=3e4;let n=!0;function w(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/ws`}function a(){if(e&&(e.readyState===WebSocket.OPEN||e.readyState===WebSocket.CONNECTING))return;let t;try{t=new WebSocket(w())}catch{s();return}e=t,t.onopen=()=>{e===t&&(c.set(!0),o=1e3)},t.onclose=()=>{e===t&&(c.set(!1),e=null,n&&s())},t.onerror=()=>{t.close()},t.onmessage=l=>{if(e===t)try{const u=JSON.parse(l.data);i.set(u)}catch{}}}function s(){setTimeout(()=>{n&&a()},o),o=Math.min(o*2,f)}function p(){n=!0,a()}function S(){n=!1,e?.close()}export{S as a,c,i as l,p as s}; | |||
There was a problem hiding this comment.
Track connecting separately from disconnected.
connected starts as false, and the Command view reads that directly to show “Disconnected — reconnecting...”. On a fresh load the banner therefore appears before the first socket attempt has even finished, which looks like a real outage.
🔧 Suggested state shape
-const connected = writable(false);
+const connectionState = writable<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');Gate the banner on 'disconnected', and set 'connecting' just before opening the socket.
| @@ -0,0 +1 @@ | |||
| import{w as M}from"./Dv0Hwark.js";import{l as N}from"./DJzzQicO.js";import"./DsnmJJEf.js";import{p as P,b as k,d as c,g as o,s as g,t as E,l as n,a0 as F}from"./CFIQ5hjj.js";import{a as h,f as b,s as m}from"./ByVGy8qs.js";import{e as I,i as z}from"./zKsXIgWY.js";import{b as A}from"./4mhR39rs.js";import{p as B}from"./BVGfD6Eb.js";const C=M([]);async function _(){try{const t=await fetch("/api/herald");t.ok&&C.set(await t.json())}catch{}}let l=null;function W(){l||(_(),l=N.subscribe(t=>{t&&(t.type==="herald-event"||t.type==="gate-resolved"||t.type==="quest-changed")&&_()}))}function X(){l?.(),l=null}var G=b('<div class="herald-item svelte-1lw2g4q"><span class="herald-icon svelte-1lw2g4q"> </span> <span class="herald-text svelte-1lw2g4q"> </span> <span class="herald-time svelte-1lw2g4q"> </span></div>'),J=b('<div class="herald-feed svelte-1lw2g4q"></div>');function Y(t,d){P(d,!0);let q=B(d,"limit",3,20);const w={gate_submitted:{icon:"◆",color:"var(--accent-gold)"},gate_approved:{icon:"✓",color:"var(--accent-green-text)"},gate_rejected:{icon:"✗",color:"var(--accent-red)"},phase_transition:{icon:"→",color:"var(--accent-green-text)"},lembas_completed:{icon:"◎",color:"var(--accent-blue)"},metadata_updated:{icon:"◎",color:"var(--text-muted)"},quest_held:{icon:"⏸",color:"var(--accent-gold)"},quest_unheld:{icon:"▶",color:"var(--accent-green-text)"}};function x(e){const s=new Date(e);if(isNaN(s.getTime()))return"?";const i=Date.now()-s.getTime(),a=Math.floor(i/6e4);if(a<1)return"now";if(a<60)return`${a}m`;const r=Math.floor(a/60);return r<24?`${r}h`:`${Math.floor(r/24)}d`}function y(e){switch(e.type){case"gate_submitted":return`${e.quest} submitted ${e.phase} gate`;case"gate_approved":return`${e.quest} ${e.phase} gate approved`;case"gate_rejected":return`${e.quest} ${e.phase} gate rejected`;case"phase_transition":return`${e.quest} entered ${e.phase}`;default:return e.detail||`${e.quest} ${e.type}`}}var p=J();I(p,21,()=>d.tidings.slice(0,q()),z,(e,s)=>{const i=F(()=>w[n(s).type]??{icon:"·",color:"var(--text-muted)"});var a=G(),r=c(a);let f;var $=c(r,!0);o(r);var u=g(r,2),j=c(u,!0);o(u);var v=g(u,2),H=c(v,!0);o(v),o(a),E((T,D)=>{f=A(r,"",f,{color:n(i).color}),m($,n(i).icon),m(j,T),m(H,D)},[()=>y(n(s)),()=>x(n(s).timestamp)]),h(e,a)}),o(p),h(t,p),k()}export{Y as H,X as a,W as s,C as t}; | |||
There was a problem hiding this comment.
Coalesce WS-triggered herald refreshes to avoid stale overwrites.
On Line 1, each matching websocket event immediately calls fetch("/api/herald") with no in-flight dedupe or response-order guard. Under bursty events, concurrent requests can resolve out of order and apply stale data last.
♻️ Proposed fix (apply in dashboard/src/lib/stores/herald.ts, then rebuild static assets)
let unsubscribe: (() => void) | null = null;
+let inFlight = false;
+let queued = false;
+let reqSeq = 0;
+let appliedSeq = 0;
export async function fetchTidings() {
+ if (inFlight) {
+ queued = true;
+ return;
+ }
+ inFlight = true;
+ const seq = ++reqSeq;
try {
const res = await fetch('/api/herald');
- if (res.ok) tidings.set(await res.json());
+ if (res.ok) {
+ const data = await res.json();
+ if (seq >= appliedSeq) {
+ appliedSeq = seq;
+ tidings.set(data);
+ }
+ }
} catch {
- // ignored
+ // handled elsewhere
+ } finally {
+ inFlight = false;
+ if (queued) {
+ queued = false;
+ void fetchTidings();
+ }
}
}| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{a4 as _e,az as Ee,a3 as Ce,b6 as xe,aS as De,w as te,ae as Se,ay as ce,ac as ue,a6 as ve,ad as Pe,a7 as Te,p as J,t as L,b as X,d as i,s as h,g as o,l as e,i as de,F as f,$ as be,_ as A,f as R,a0 as ae,a1 as qe,e as Qe,b7 as Ne}from"../chunks/CFIQ5hjj.js";import{d as oe,b as O,a as _,f as E,s as P,e as Fe,c as we}from"../chunks/ByVGy8qs.js";import{B as Ie,i as K}from"../chunks/537vdszY.js";import{s as Ae,a as Me}from"../chunks/DLS4WeON.js";import{e as W,i as U}from"../chunks/zKsXIgWY.js";import{a as re,r as fe}from"../chunks/DdAmTr_Q.js";import{s as se}from"../chunks/CQvy7xpd.js";import{p as Le}from"../chunks/BVGfD6Eb.js";import{p as pe}from"../chunks/eQSYeF-0.js";import{b as me}from"../chunks/jKnW4BRh.js";import{b as he}from"../chunks/CSXl7R3t.js";import{g as H}from"../chunks/DDgnzpUj.js";import{e as Ke,g as Oe,k as Re,h as Ge}from"../chunks/c59pmuMM.js";import{w as je}from"../chunks/Dv0Hwark.js";import{l as Be}from"../chunks/DJzzQicO.js";function ye(d,r,...s){var b=new Ie(d);_e(()=>{const n=r()??null;b.ensure(n,n&&(t=>n(t,...s)))},Ee)}function He(d,r){let s=null,b=te;var n;if(te){s=Pe;for(var t=Te(document.head);t!==null&&(t.nodeType!==Se||t.data!==d);)t=ce(t);if(t===null)ue(!1);else{var v=ce(t);t.remove(),ve(v)}}te||(n=document.head.appendChild(Ce()));try{_e(()=>r(n),xe|De)}finally{b&&(ue(!0),ve(s))}}const ze=!1,We=!1,Qt=Object.freeze(Object.defineProperty({__proto__:null,prerender:ze,ssr:We},Symbol.toStringTag,{value:"Module"}));var Ue=E('<span class="logo-text svelte-129hoe0">Fellowship</span>'),Ve=E('<span class="nav-label"> </span>'),Je=E('<a><span class="nav-icon svelte-129hoe0"> </span> <!></a>'),Xe=E('<aside><div class="sidebar-header svelte-129hoe0"><div class="logo-mark svelte-129hoe0">F</div> <!></div> <nav class="sidebar-nav svelte-129hoe0"></nav> <button class="sidebar-toggle svelte-129hoe0"> </button></aside>');function Ye(d,r){J(r,!0);let s=Le(r,"collapsed",15,!1);const b=[{path:"/command",label:"Command",icon:"⚔"},{path:"/herald",label:"Herald",icon:"📜"},{path:"/autopsies",label:"Autopsies",icon:"◆"},{path:"/timeline",label:"Timeline",icon:"▰"},{path:"/config",label:"Config",icon:"⚙"}];function n(k){return pe.url.pathname===k||pe.url.pathname.startsWith(k+"/")}var t=Xe();let v;var C=i(t),p=h(i(C),2);{var u=k=>{var x=Ue();_(k,x)};K(p,k=>{s()||k(u)})}o(C);var y=h(C,2);W(y,21,()=>b,U,(k,x)=>{var w=Je();let N;var l=i(w),F=i(l,!0);o(l);var I=h(l,2);{var a=m=>{var c=Ve(),S=i(c,!0);o(c),L(()=>P(S,e(x).label)),_(m,c)};K(I,m=>{s()||m(a)})}o(w),L(m=>{re(w,"href",e(x).path),N=se(w,1,"nav-item svelte-129hoe0",null,N,m),re(w,"title",s()?e(x).label:void 0),P(F,e(x).icon)},[()=>({active:n(e(x).path)})]),_(k,w)}),o(y);var g=h(y,2),D=i(g,!0);o(g),o(t),L(()=>{v=se(t,1,"sidebar svelte-129hoe0",null,v,{collapsed:s()}),P(D,s()?"›":"‹")}),O("click",g,()=>s(!s())),_(d,t),X()}oe(["click"]);var Ze=E('<div class="input-mode-header svelte-wh9uu8"> </div> <input class="search-input svelte-wh9uu8" type="text"/>',1),$e=E("<button> </button>"),et=E('<div class="category-header svelte-wh9uu8"> </div> <!>',1),tt=E('<div class="no-results svelte-wh9uu8">No matching commands</div>'),at=E('<input class="search-input svelte-wh9uu8" placeholder="Search commands..." type="text"/> <div class="action-list svelte-wh9uu8"><!> <!></div>',1),rt=E('<div class="overlay svelte-wh9uu8"><div class="panel svelte-wh9uu8"><!></div></div>');function st(d,r){J(r,!0);const s=[{label:"Spawn Quest",category:"Quest Control",action:()=>{f(t,"spawn-quest")},inputPlaceholder:"Enter task description..."},{label:"Spawn Scout",category:"Quest Control",action:()=>{f(t,"spawn-scout")},inputPlaceholder:"Enter question..."},{label:"Kill Quest",category:"Quest Control",action:()=>{f(t,"kill-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Restart Quest",category:"Quest Control",action:()=>{f(t,"restart-quest")},inputPlaceholder:"Enter quest ID..."},{label:"Go to Command",category:"Navigation",action:()=>{H("/command"),r.onClose()}},{label:"Go to Herald",category:"Navigation",action:()=>{H("/herald"),r.onClose()}},{label:"Go to Autopsies",category:"Navigation",action:()=>{H("/autopsies"),r.onClose()}},{label:"Go to Timeline",category:"Navigation",action:()=>{H("/timeline"),r.onClose()}},{label:"Go to Config",category:"Navigation",action:()=>{H("/config"),r.onClose()}}];let b=A(""),n=A(0),t=A(null),v=A(""),C=A(void 0),p=A(void 0),u=ae(()=>e(b)?s.filter(a=>a.label.toLowerCase().includes(e(b).toLowerCase())):s),y=ae(()=>{const a=[],m=new Set;for(const c of e(u))m.has(c.category)||(m.add(c.category),a.push({category:c.category,items:[]})),a.find(S=>S.category===c.category).items.push(c);return a});function g(){return e(u)}function D(){const a=g();a[e(n)]&&a[e(n)].action()}async function k(){if(e(v).trim())try{e(t)==="spawn-quest"?await Ke(e(v).trim()):e(t)==="spawn-scout"?await Oe(e(v).trim()):e(t)==="kill-quest"?await Re(e(v).trim()):e(t)==="restart-quest"&&await Ge(e(v).trim()),f(t,null),f(v,""),r.onClose()}catch{}}function x(a){if(e(t)){a.key==="Escape"?(a.preventDefault(),f(t,null),f(v,""),e(C)?.focus()):a.key==="Enter"&&(a.preventDefault(),k());return}a.key==="Escape"?(a.preventDefault(),r.onClose()):a.key==="ArrowDown"?(a.preventDefault(),e(u).length>0&&f(n,(e(n)+1)%e(u).length)):a.key==="ArrowUp"?(a.preventDefault(),e(u).length>0&&f(n,(e(n)-1+e(u).length)%e(u).length)):a.key==="Enter"&&(a.preventDefault(),D())}de(()=>{e(b),f(n,0)}),de(()=>{e(t)&&e(p)&&e(p).focus()}),be(()=>{e(C)?.focus()});var w=rt(),N=i(w),l=i(N);{var F=a=>{var m=Ze(),c=R(m),S=i(c,!0);o(c);var T=h(c,2);fe(T),he(T,q=>f(p,q),()=>e(p)),L(q=>{P(S,e(t)==="spawn-quest"?"Spawn Quest":e(t)==="spawn-scout"?"Spawn Scout":e(t)==="kill-quest"?"Kill Quest":"Restart Quest"),re(T,"placeholder",q)},[()=>s.find(q=>q.label.toLowerCase().replace(/\s+/g,"-")===e(t)?.replace("spawn-","spawn-").replace("kill-","kill-").replace("restart-","restart-"))?.inputPlaceholder??"Enter value..."]),me(T,()=>e(v),q=>f(v,q)),_(a,m)},I=a=>{var m=at(),c=R(m);fe(c),he(c,Q=>f(C,Q),()=>e(C));var S=h(c,2),T=i(S);W(T,17,()=>e(y),U,(Q,M)=>{var z=et(),G=R(z),Y=i(G,!0);o(G);var Z=h(G,2);W(Z,17,()=>e(M).items,U,($,ee)=>{const le=ae(()=>e(u).indexOf(e(ee)));var j=$e();let ie;var ke=i(j,!0);o(j),L(()=>{ie=se(j,1,"action-item svelte-wh9uu8",null,ie,{selected:e(le)===e(n)}),P(ke,e(ee).label)}),Fe("mouseenter",j,()=>f(n,e(le),!0)),O("click",j,()=>e(ee).action()),_($,j)}),L(()=>P(Y,e(M).category)),_(Q,z)});var q=h(T,2);{var B=Q=>{var M=tt();_(Q,M)};K(q,Q=>{e(u).length===0&&Q(B)})}o(S),me(c,()=>e(b),Q=>f(b,Q)),_(a,m)};K(l,a=>{e(t)?a(F):a(I,-1)})}o(N),o(w),O("keydown",w,x),O("click",w,function(...a){r.onClose?.apply(this,a)}),O("click",N,a=>a.stopPropagation()),_(d,w),X()}oe(["keydown","click"]);const ne=je([]);async function ge(){try{const d=await fetch("/api/errors");d.ok&&ne.set(await d.json())}catch{}}async function ot(){try{(await fetch("/api/errors",{method:"DELETE"})).ok&&ne.set([])}catch{}}let V=null;function nt(){V||(ge(),V=Be.subscribe(d=>{d&&d.type==="error-logged"&&ge()}))}function lt(){V?.(),V=null}var it=E('<div class="error-item svelte-vde8u4"><span class="error-source svelte-vde8u4"> </span> <span class="error-handler svelte-vde8u4"> </span> <span class="error-message svelte-vde8u4"> </span> <span class="error-time svelte-vde8u4"> </span></div>'),ct=E('<div class="error-list svelte-vde8u4"></div> <button class="error-clear svelte-vde8u4">Clear all</button>',1),ut=E('<div class="error-banner svelte-vde8u4"><button class="error-summary svelte-vde8u4"><span class="error-icon svelte-vde8u4">!</span> <span class="error-count svelte-vde8u4"> </span> <span class="error-toggle svelte-vde8u4"> </span></button> <!></div>');function vt(d,r){J(r,!0);let s=A(!1);function b(p){const u=new Date(p);if(isNaN(u.getTime()))return"?";const y=Date.now()-u.getTime(),g=Math.floor(y/6e4);if(g<1)return"now";if(g<60)return`${g}m ago`;const D=Math.floor(g/60);return D<24?`${D}h ago`:`${Math.floor(D/24)}d ago`}function n(p){switch(p){case"api":return"API";case"websocket":return"WS";case"polling":return"Poll";default:return p}}var t=we(),v=R(t);{var C=p=>{var u=ut(),y=i(u),g=h(i(y),2),D=i(g);o(g);var k=h(g,2),x=i(k,!0);o(k),o(y);var w=h(y,2);{var N=l=>{var F=ct(),I=R(F);W(I,21,()=>r.errors,U,(m,c)=>{var S=it(),T=i(S),q=i(T,!0);o(T);var B=h(T,2),Q=i(B,!0);o(B);var M=h(B,2),z=i(M,!0);o(M);var G=h(M,2),Y=i(G,!0);o(G),o(S),L((Z,$)=>{P(q,Z),P(Q,e(c).handler),P(z,e(c).message),P(Y,$)},[()=>n(e(c).source),()=>b(e(c).timestamp)]),_(m,S)}),o(I);var a=h(I,2);O("click",a,function(...m){ot?.apply(this,m)}),_(l,F)};K(w,l=>{e(s)&&l(N)})}o(u),L(()=>{P(D,`${r.errors.length??""} server error${r.errors.length!==1?"s":""}`),P(x,e(s)?"▾":"▸")}),O("click",y,()=>f(s,!e(s))),_(p,u)};K(v,p=>{r.errors.length>0&&p(C)})}_(d,t),X()}oe(["click"]);var dt=E('<div class="error-banner-wrapper svelte-w96i92"><!></div>'),ft=E('<div class="shell svelte-w96i92"><!> <main class="main-content svelte-w96i92"><!> <!></main></div> <!>',1);function pt(d,r){J(r,!0);const s=()=>Me(ne,"$errors",b),[b,n]=Ae();let t=A(!1),v=A(!1);function C(l){l.key.toLowerCase()==="k"&&(l.metaKey||l.ctrlKey)&&(l.preventDefault(),f(v,!e(v)))}be(()=>{window.addEventListener("keydown",C),nt()}),qe(()=>{window.removeEventListener("keydown",C),lt()});var p=ft(),u=R(p),y=i(u);Ye(y,{get collapsed(){return e(t)},set collapsed(l){f(t,l,!0)}});var g=h(y,2),D=i(g);{var k=l=>{var F=dt(),I=i(F);vt(I,{get errors(){return s()}}),o(F),_(l,F)};K(D,l=>{s().length>0&&l(k)})}var x=h(D,2);ye(x,()=>r.children),o(g),o(u);var w=h(u,2);{var N=l=>{st(l,{onClose:()=>f(v,!1)})};K(w,l=>{e(v)&&l(N)})}_(d,p),X(),n()}function Nt(d,r){He("12qhfyh",s=>{Qe(()=>{Ne.title="Fellowship Dashboard"})}),pt(d,{children:(s,b)=>{var n=we(),t=R(n);ye(t,()=>r.children),_(s,n)},$$slots:{default:!0}})}export{Nt as component,Qt as universal}; | |||
There was a problem hiding this comment.
Ignore Cmd/Ctrl+K while focus is inside an editable control.
The window-level shortcut toggles the palette even when the active element is an input, textarea, select, or contenteditable node. That interrupts typing on the Config page and can close the palette while the user is entering command arguments. Please fix this in the source layout and rebuild the bundle.
The current matcher is substring-only, not fuzzy.
The palette filter uses includes(...), so queries like sq will not find “Spawn Quest”. If fuzzy search is part of the acceptance criteria for this PR, the source command-palette matcher needs a scorer or at least ordered-subsequence matching before release.
| @@ -0,0 +1 @@ | |||
| import"../chunks/DsnmJJEf.js";import{d as a,l as e,g as t,s as r,t as x,p as Ge,i as fe,F as p,b as Oe,a0 as M,_ as N,f as he}from"../chunks/CFIQ5hjj.js";import{s as Ie,a as ie}from"../chunks/DLS4WeON.js";import{s as c,a as o,t as ye,f as u,d as Ne,b as D}from"../chunks/ByVGy8qs.js";import{i as S}from"../chunks/537vdszY.js";import{e as Q,i as ve}from"../chunks/zKsXIgWY.js";import{a as Pe}from"../chunks/DdAmTr_Q.js";import{s as b}from"../chunks/CQvy7xpd.js";import{p as We}from"../chunks/eQSYeF-0.js";import{d as Ae,q as Be}from"../chunks/4mhR39rs.js";import{c as De,d as Me}from"../chunks/c59pmuMM.js";import{G as Qe}from"../chunks/C19yGuqu.js";import{H as Ue,t as je}from"../chunks/DM7OFrYY.js";var ze=u('<div class="errand-item svelte-6y5tqw"><div><!></div> <span> </span></div>'),Je=u('<div class="errand-list svelte-6y5tqw"></div>');function Ke(U,j){var P=Je();Q(P,21,()=>j.errands,W=>W.id,(W,E)=>{var F=ze(),O=a(F),q=a(O);{var v=_=>{var i=ye("✓");o(_,i)},$=_=>{var i=ye("●");o(_,i)};S(q,_=>{e(E).status==="done"?_(v):e(E).status==="active"&&_($,1)})}t(O);var A=r(O,2),w=a(A,!0);t(A),t(F),x(()=>{b(O,1,`errand-check ${e(E).status??""}`,"svelte-6y5tqw"),b(A,1,`errand-text ${e(E).status??""}`,"svelte-6y5tqw"),c(w,e(E).description)}),o(W,F)}),t(P),o(U,P)}var Ve=u('<span></span> <span class="health-label svelte-1ysin3l"> </span>',1),Xe=u('<div><span class="phase-step-label"> </span></div>'),Ye=u('<div class="title-row svelte-1ysin3l"><h1 class="svelte-1ysin3l"> </h1> <span class="phase-badge svelte-1ysin3l"> </span> <!> <!></div> <div class="phase-steps svelte-1ysin3l"></div>',1),Ze=u('<span class="tab-badge svelte-1ysin3l"> </span>'),es=u('<div class="file-item svelte-1ysin3l"> </div>'),ss=u('<div class="files-list svelte-1ysin3l"></div>'),ts=u('<div class="tome-entry svelte-1ysin3l"><span> </span> <span class="tome-phase svelte-1ysin3l"> </span> <span class="tome-time svelte-1ysin3l"> </span></div>'),as=u('<div class="tome-content svelte-1ysin3l"><h3 class="svelte-1ysin3l">Gate History</h3> <!></div>'),ls=u('<div class="logs-placeholder"><p class="empty svelte-1ysin3l">Quest logs not yet available. Raw output logging is a future enhancement.</p></div>'),rs=u('<p class="empty svelte-1ysin3l">No data available</p>'),is=u('<div class="meta-row svelte-1ysin3l"><span>Eagles</span> <span> </span></div>'),vs=u('<div class="detail-meta svelte-1ysin3l"><div class="meta-card svelte-1ysin3l"><div class="meta-title svelte-1ysin3l">Metadata</div> <div class="meta-row svelte-1ysin3l"><span>Branch</span> <span class="meta-value mono svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Worktree</span> <span class="meta-value mono truncate svelte-1ysin3l"> </span></div> <div class="meta-row svelte-1ysin3l"><span>Status</span> <span class="meta-value svelte-1ysin3l"> </span></div> <!></div></div>'),ns=u('<div class="detail-view svelte-1ysin3l"><div class="detail-header svelte-1ysin3l"><div class="breadcrumb svelte-1ysin3l"><a href="/command" class="svelte-1ysin3l">Command</a> <span class="sep svelte-1ysin3l">/</span> <span> </span></div> <!></div> <div class="tabs svelte-1ysin3l"><button>Errands <!></button> <button>Files</button> <button>Tome</button> <button>Herald</button> <button>Logs</button></div> <div class="detail-body svelte-1ysin3l"><div class="detail-content svelte-1ysin3l"><!></div> <!></div></div>');function ws(U,j){Ge(j,!0);const P=()=>ie(Ae,"$dashboardStatus",F),W=()=>ie(Be,"$questHealths",F),E=()=>ie(je,"$tidings",F),[F,O]=Ie();let q=M(()=>decodeURIComponent(We.params.id)),v=M(()=>P()?.quests.find(s=>s.name===e(q))),$=M(()=>W().find(s=>s.name===e(q))),A=M(()=>E().filter(s=>s.quest===e(q))),w=N(null),_=N(null),i=N("errands");const z=["Onboard","Research","Plan","Implement","Review","Complete"];let J=N(!1),K=N(!1),V=N(!1);fe(()=>{e(q),p(J,!1),p(K,!1),p(w,null),p(_,null)}),fe(()=>{if(e(v)&&!e(J)&&!e(K)&&!e(V)){p(V,!0);const s=e(v).worktree;Promise.all([De(s),Me(e(q))]).then(([l,n])=>{p(w,l,!0),p(_,n,!0),p(J,!0)}).catch(()=>{p(K,!0)}).finally(()=>{p(V,!1)})}});function be(s){if(!e(v))return"pending";const l=z.indexOf(e(v).phase);if(l===-1)return"pending";const n=z.indexOf(s);return n<l?"done":n===l?"current":"pending"}var X=ns(),Y=a(X),Z=a(Y),ne=r(a(Z),4),ge=a(ne,!0);t(ne),t(Z);var xe=r(Z,2);{var we=s=>{var l=Ye(),n=he(l),d=a(n),m=a(d,!0);t(d);var h=r(d,2),g=a(h,!0);t(h);var H=r(h,2);{var L=y=>{Qe(y,{get worktree(){return e(v).worktree}})};S(H,y=>{e(v).gate_pending&&y(L)})}var R=r(H,2);{var T=y=>{var C=Ve(),f=he(C),k=r(f,2),G=a(k,!0);t(k),x(()=>{b(f,1,`health-dot ${e($).health??""}`,"svelte-1ysin3l"),c(G,e($).health)}),o(y,C)};S(R,y=>{e($)&&y(T)})}t(n);var I=r(n,2);Q(I,21,()=>z,ve,(y,C)=>{var f=Xe(),k=a(f),G=a(k,!0);t(k),t(f),x(re=>{b(f,1,`phase-step ${re??""}`,"svelte-1ysin3l"),c(G,e(C))},[()=>be(e(C))]),o(y,f)}),t(I),x(()=>{c(m,e(v).name),c(g,e(v).phase)}),o(s,l)};S(xe,s=>{e(v)&&s(we)})}t(Y);var ee=r(Y,2),B=a(ee);let oe;var ke=r(a(B));{var qe=s=>{var l=Ze(),n=a(l);t(l),x(d=>c(n,`${d??""}/${e(w).items.length??""}`),[()=>e(w).items.filter(d=>d.status==="done").length]),o(s,l)};S(ke,s=>{e(w)&&s(qe)})}t(B);var se=r(B,2);let de;var te=r(se,2);let pe;var ae=r(te,2);let ce;var ue=r(ae,2);let _e;t(ee);var me=r(ee,2),le=a(me),$e=a(le);{var He=s=>{Ke(s,{get errands(){return e(w).items}})},Se=s=>{var l=ss();Q(l,21,()=>e(_).files_touched,ve,(n,d)=>{var m=es(),h=a(m,!0);t(m),x(()=>c(h,e(d))),o(n,m)}),t(l),o(s,l)},Ee=s=>{var l=as(),n=r(a(l),2);Q(n,17,()=>e(_).gate_history,ve,(d,m)=>{var h=ts(),g=a(h),H=a(g,!0);t(g);var L=r(g,2),R=a(L,!0);t(L);var T=r(L,2),I=a(T,!0);t(T),t(h),x(()=>{b(g,1,`tome-action ${e(m).action??""}`,"svelte-1ysin3l"),c(H,e(m).action),c(R,e(m).phase),c(I,e(m).timestamp)}),o(d,h)}),t(l),o(s,l)},Fe=s=>{Ue(s,{get tidings(){return e(A)},limit:50})},Le=s=>{var l=ls();o(s,l)},Re=s=>{var l=rs();o(s,l)};S($e,s=>{e(i)==="errands"&&e(w)?s(He):e(i)==="files"&&e(_)?s(Se,1):e(i)==="tome"&&e(_)?s(Ee,2):e(i)==="herald"?s(Fe,3):e(i)==="logs"?s(Le,4):s(Re,-1)})}t(le);var Te=r(le,2);{var Ce=s=>{var l=vs(),n=a(l),d=r(a(n),2),m=r(a(d),2),h=a(m,!0);t(m),t(d);var g=r(d,2),H=r(a(g),2),L=a(H,!0);t(H),t(g);var R=r(g,2),T=r(a(R),2),I=a(T,!0);t(T),t(R);var y=r(R,2);{var C=f=>{var k=is(),G=r(a(k),2),re=a(G,!0);t(G),t(k),x(()=>{b(G,1,`meta-value ${e($).health??""}`,"svelte-1ysin3l"),c(re,e($).health)}),o(f,k)};S(y,f=>{e($)&&f(C)})}t(n),t(l),x(f=>{c(h,f),Pe(H,"title",e(v).worktree),c(L,e(v).worktree),c(I,e(v).status)},[()=>e(v).worktree.replace(/\\/g,"/").split("/").pop()||e(v).worktree]),o(s,l)};S(Te,s=>{e(v)&&s(Ce)})}t(me),t(X),x(()=>{c(ge,e(q)),oe=b(B,1,"tab svelte-1ysin3l",null,oe,{active:e(i)==="errands"}),de=b(se,1,"tab svelte-1ysin3l",null,de,{active:e(i)==="files"}),pe=b(te,1,"tab svelte-1ysin3l",null,pe,{active:e(i)==="tome"}),ce=b(ae,1,"tab svelte-1ysin3l",null,ce,{active:e(i)==="herald"}),_e=b(ue,1,"tab svelte-1ysin3l",null,_e,{active:e(i)==="logs"})}),D("click",B,()=>p(i,"errands")),D("click",se,()=>p(i,"files")),D("click",te,()=>p(i,"tome")),D("click",ae,()=>p(i,"herald")),D("click",ue,()=>p(i,"logs")),o(U,X),Oe(),O()}Ne(["click"]);export{ws as component}; | |||
There was a problem hiding this comment.
Drop stale quest-detail responses before writing local state.
The Promise.all([De(s), Me(e(q))]) result is committed into w/_ unconditionally. If the user switches quests before it resolves, the older response can overwrite the new quest's detail pane, and the J guard then suppresses any corrective refetch until the id changes.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python - <<'PY'
from pathlib import Path
checks = [
(
"runtime param decoding",
Path("cli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.js"),
["decodeURIComponent(e[t])", "params:wt("],
),
(
"quest route extra decode",
Path("cli/internal/dashboard/static/_app/immutable/nodes/7.BQnCdFNo.js"),
["decodeURIComponent(We.params.id)"],
),
]
for label, path, needles in checks:
text = path.read_text()
print(f"== {label}: {path} ==")
for needle in needles:
idx = text.find(needle)
print(f"-- {needle} --")
if idx == -1:
print("NOT FOUND\n")
continue
start = max(0, idx - 120)
end = min(len(text), idx + len(needle) + 120)
print(text[start:end])
print()
PYRepository: justinjdev/fellowship
Length of output: 1134
Remove decodeURIComponent() call; params.id is already decoded by the runtime.
The SvelteKit runtime in cli/internal/dashboard/static/_app/immutable/chunks/DDgnzpUj.js runs decodeURIComponent() on all route params via the wt() function before the route receives them. Calling decodeURIComponent(We.params.id) again in the quest route causes double-decoding, which will throw for ids containing % and corrupt any id with percent-escaped bytes. Remove the extra decode and rebuild the bundle.
| export async function fetchErrors() { | ||
| try { | ||
| const res = await fetch('/api/errors'); | ||
| if (res.ok) errors.set(await res.json()); | ||
| } catch { /* offline */ } | ||
| } | ||
|
|
||
| export async function clearErrors() { | ||
| try { | ||
| const res = await fetch('/api/errors', { method: 'DELETE' }); | ||
| if (res.ok) errors.set([]); | ||
| } catch { /* offline */ } | ||
| } | ||
|
|
||
| let unsubscribe: (() => void) | null = null; | ||
|
|
||
| export function startErrorPolling() { | ||
| if (unsubscribe) return; | ||
| fetchErrors(); | ||
| unsubscribe = lastEvent.subscribe((event) => { | ||
| if (!event) return; | ||
| if (event.type === 'error-logged') { | ||
| fetchErrors(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Serialize /api/errors refreshes before mutating the store.
The GET on Line 9 can run from the initial load on Line 25 and again from Line 29, so a slower response can land after the DELETE on Line 16 and make cleared errors reappear. Please gate errors.set(...) behind a request token or abort stale GETs.
🛠️ One way to make the updates monotonic
export const errors = writable<DashboardError[]>([]);
+let refreshToken = 0;
export async function fetchErrors() {
+ const token = ++refreshToken;
try {
const res = await fetch('/api/errors');
- if (res.ok) errors.set(await res.json());
+ if (!res.ok) return;
+ const next = await res.json();
+ if (token === refreshToken) errors.set(next);
} catch { /* offline */ }
}
export async function clearErrors() {
+ refreshToken++;
try {
const res = await fetch('/api/errors', { method: 'DELETE' });
if (res.ok) errors.set([]);
} catch { /* offline */ }
}| let unsubscribe: (() => void) | null = null; | ||
| let refreshInFlight: Promise<void> | null = null; | ||
|
|
||
| function scheduleRefresh() { | ||
| if (refreshInFlight) return refreshInFlight; | ||
| refreshInFlight = refreshAll().finally(() => { | ||
| refreshInFlight = null; | ||
| }); | ||
| return refreshInFlight; | ||
| } |
There was a problem hiding this comment.
Queue one trailing refresh instead of dropping mid-flight WS events.
While refreshInFlight is set, scheduleRefresh() just returns the current promise. If another event lands after the active fetches have already read old state, no follow-up refresh runs and these stores can stay stale until a later event.
♻️ Suggested fix
let unsubscribe: (() => void) | null = null;
let refreshInFlight: Promise<void> | null = null;
+let refreshQueued = false;
function scheduleRefresh() {
- if (refreshInFlight) return refreshInFlight;
- refreshInFlight = refreshAll().finally(() => {
+ if (refreshInFlight) {
+ refreshQueued = true;
+ return refreshInFlight;
+ }
+ refreshInFlight = (async () => {
+ do {
+ refreshQueued = false;
+ await refreshAll();
+ } while (refreshQueued);
+ })().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}| function getScope(key: string): 'project' | 'global' { | ||
| return key in projectConfig ? 'project' : 'global'; | ||
| } |
There was a problem hiding this comment.
This scope heuristic cannot create the first project override.
Line 31 returns 'global' for every known key that is not already present in projectConfig, so a fresh repo can never create the first project-scoped value from this UI. Since saveConfig(...) and the backend already support both scopes, this needs an explicit scope chooser instead of inferring scope purely from current presence.
| {#each knownKeys as setting} | ||
| {@const val = getEffectiveValue(setting.key)} | ||
| {@const scope = getScope(setting.key)} | ||
| <div class="setting-row"> | ||
| <div class="setting-info"> | ||
| <span class="setting-label">{setting.label}</span> | ||
| <span class="setting-scope">{scope}</span> | ||
| </div> | ||
| <div class="setting-control"> | ||
| {#if setting.type === 'boolean'} | ||
| <button | ||
| class="toggle-btn" | ||
| class:active={!!val} | ||
| disabled={saving === setting.key} | ||
| onclick={() => handleToggle(setting.key)} | ||
| > | ||
| {val ? 'ON' : 'OFF'} | ||
| </button> | ||
| {:else if setting.type === 'text'} | ||
| <input | ||
| type="text" | ||
| class="setting-input" | ||
| value={val ?? ''} | ||
| disabled={saving === setting.key} | ||
| onblur={(e) => handleText(setting.key, e)} | ||
| /> | ||
| {:else if setting.type === 'select'} | ||
| <select | ||
| class="setting-select" | ||
| value={val ?? setting.options?.[0]} | ||
| disabled={saving === setting.key} | ||
| onchange={(e) => handleSelect(setting.key, e)} | ||
| > | ||
| {#each setting.options ?? [] as opt} | ||
| <option value={opt}>{opt}</option> | ||
| {/each} |
There was a problem hiding this comment.
Give each setting control an accessible name.
The visible setting text is not associated with the button/input/select controls, so screen readers will announce the text/select fields as unlabeled and the toggle as just “ON/OFF”. That makes the Config view much harder to operate with assistive tech.
♿ Minimal accessibility fix
{`#each` knownKeys as setting}
{`@const` val = getEffectiveValue(setting.key)}
{`@const` scope = getScope(setting.key)}
+ {`@const` labelId = `setting-${setting.key}-label`}
<div class="setting-row">
<div class="setting-info">
- <span class="setting-label">{setting.label}</span>
+ <span class="setting-label" id={labelId}>{setting.label}</span>
<span class="setting-scope">{scope}</span>
</div>
<div class="setting-control">
{`#if` setting.type === 'boolean'}
<button
class="toggle-btn"
class:active={!!val}
+ aria-labelledby={labelId}
+ aria-pressed={!!val}
disabled={saving === setting.key}
onclick={() => handleToggle(setting.key)}
>
{val ? 'ON' : 'OFF'}
</button>
{:else if setting.type === 'text'}
<input
type="text"
class="setting-input"
+ aria-labelledby={labelId}
value={val ?? ''}
disabled={saving === setting.key}
onblur={(e) => handleText(setting.key, e)}
/>
{:else if setting.type === 'select'}
<select
class="setting-select"
+ aria-labelledby={labelId}
value={val ?? setting.options?.[0]}
disabled={saving === setting.key}
onchange={(e) => handleSelect(setting.key, e)}
>
Summary
embed.FS, SPA fallback routing for client-side navigationArchitecture
Dashboard communicates with Go server via REST (state fetching, commands) + WebSocket (real-time notifications). Commands dispatch through a file-backed queue (
command-queue.json) that Gandalf polls to execute agent actions. Dark "War Table" design system with Cinzel/Outfit/DM Sans/JetBrains Mono typography and gold accent palette.Test plan
cd cli && go test ./internal/dashboard/ -v— unit tests for WebSocket hub and command queuecd dashboard && npm run build— verify Svelte compilationdashboard/build.sh— verify build pipeline copies assets to Go embed dircd cli && go build ./...— verify Go compiles with embedded dashboard🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores