feat(slices,ui): typed slice-tag preference + Explorer refresh + queue DESC sort

V0.3.1 slice 17 founder-walk-workspace-state-correctness. Three
correctness fixes from the 2026-05-11 founder walk (items 3, 8, 10).

(1) Item 3 — qitems-per-slice filter (slice-indexer matchQitems):
    pre-fix unioned `body LIKE` / `tags LIKE` on
    [sliceName, railItem, missionId]. The missionId substring term
    over-matched: a qitem tagged only `mission:<id>` appeared under
    EVERY slice in that mission. Fix: when at least one qitem has the
    typed `slice:<sliceName>` tag, the missionId substring term is
    dropped — the typed tag is the authoritative slice membership
    signal. Substring fallback (including missionId) preserved when
    no typed-tag rows exist so legacy corpora don't regress.

(2) Item 8 — Explorer auto-show:
    - new POST /api/slices/refresh route invokes indexer.invalidate()
      to drop both listing + detail caches
    - new useRefreshSlices mutation hook posts to /refresh then
      invalidates the slices + files react-query caches
    - ProjectTreeView gains a refresh button (RefreshCw lucide icon
      with spin-while-pending) on its header
    - useSlices + useFilesList opt into refetchOnWindowFocus so the
      common "switched away to mkdir + came back" path picks up new
      folders without manual click

(3) Item 10 — slice queue descending order:
    ScopeQueueRollup sorts qitemIds DESC by tsCreated (with qitem-id
    fallback before queueItemsById is loaded, since the id encodes the
    timestamp prefix). Latest qitem at top.

Tests:
- New `when typed slice:<name> tag matches exist, mission-tagged-only
  qitems are NOT included` (red→green proof of the over-match fix)
- New `preserves legacy substring fallback (including mission body)
  when NO typed slice: tag exists` (HG-2 — no regression for legacy
  corpora)
- New POST /api/slices/refresh tests (cache reset + 503 envelope)

slice-indexer 27/27 + slices-routes 21/21 PASS. Full daemon suite
3351 passed / 1 skipped / 0 failed on clean re-run (initial 6
failures were flaky 5s createDaemon timeouts that resolved on
re-run — same pattern banked during slice 05). UI typecheck on
touched files clean; pre-existing tanstack-react-table errors in
SpecsTable + TopologyTableView unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 04:36:17 -07:00
1 parent 3c75cb2282
commit 1341572c89
8 files changed
+233 -12

No files matched your search

@@ -407,21 +407,51 @@ export class SliceIndexer {
}
private matchQitems(sliceName: string, railItem: string | null, missionId: string | null): string[] {
// Strategy: union qitems whose body or tags mention the slice id,
// rail item, or mission id. Body matching preserves the legacy corpus;
// tags make the mission-aware default workspace precise for new work.
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
// item 3): the previous implementation unioned substring matches on
// [sliceName, railItem, missionId]. The missionId term over-matched
// — every qitem tagged `mission:<id>` appeared under EVERY slice in
// that mission. Fix: when typed `slice:<name>` tag rows exist for
// this slice, the missionId substring term is dropped from the
// union; the typed tag is the authoritative slice membership signal.
// Substring fallback (including the missionId term) is preserved for
// slices whose qitem corpus pre-dates the typed-tag convention so
// legacy mission-aware workspaces don't regress.
const ids = new Set<string>();
const terms = Array.from(new Set([sliceName, railItem, missionId].filter((v): v is string => !!v)));
try {
for (const term of terms) {
// 1. Typed-tag matches: tags JSON contains `slice:<sliceName>` literal.
// Both the `"slice:..."` JSON-array form and the comma-separated
// form (legacy CLI tags) are caught by a wildcard on the slice id.
let typedTagMatchCount = 0;
try {
const rows = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE tags LIKE ? LIMIT 500`,
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string }>;
for (const r of rows) ids.add(r.qitem_id);
typedTagMatchCount = rows.length;
} catch {
// queue_items present but `tags` column missing (older test
// harness): fall through to substring path with full term set.
}
// 2. Substring matches. Terms are chosen so the missionId term
// is INCLUDED only when no typed-tag rows exist (legacy
// compatibility); otherwise the slice-specific terms (sliceName +
// railItem) drive matching. railItem stays in both branches
// because it's slice-scoped, not mission-scoped.
const substringTerms = (typedTagMatchCount > 0
? [sliceName, railItem]
: [sliceName, railItem, missionId]
).filter((v): v is string => !!v);
for (const term of Array.from(new Set(substringTerms))) {
try {
const rows = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`
`SELECT qitem_id FROM queue_items WHERE body LIKE ? OR tags LIKE ? LIMIT 500`,
).all(`%${term}%`, `%${term}%`) as Array<{ qitem_id: string }>;
for (const r of rows) ids.add(r.qitem_id);
} catch {
const rows = this.db.prepare(
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`
`SELECT qitem_id FROM queue_items WHERE body LIKE ? LIMIT 500`,
).all(`%${term}%`) as Array<{ qitem_id: string }>;
for (const r of rows) ids.add(r.qitem_id);
}
+12
View File
@@ -91,6 +91,18 @@ export function slicesRoutes(): Hono {
});
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
// item 8 — Explorer auto-show): explicit cache invalidation surface.
// POST /api/slices/refresh drops both indexer caches so newly-created
// slice / mission folders are picked up without a daemon restart.
// Registered BEFORE the dynamic /:name routes so it isn't shadowed.
app.post("/refresh", (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
deps.indexer.invalidate();
return c.json({ ok: true });
});
// 2) Proof asset serving — registered BEFORE /:name to keep /:name from
// eating /proof-asset paths. Hono's :wildcard matches a single
// segment; we parse the rest of the path manually for nested
@@ -281,6 +281,67 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
expect(slice.qitemIds.sort()).toEqual(["q-by-mission-body", "q-by-slice-tag"]);
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder item 3.
// The over-match bug: a qitem tagged ONLY mission:<missionId>
// (no `slice:` tag, no slice-name body mention) was returning
// under EVERY slice in that mission because matchQitems unioned
// the missionId substring term across all slices. Fix: when at
// least one qitem has the typed `slice:<sliceName>` tag, the
// missionId substring term is dropped from the union so
// mission-tagged-only qitems no longer pollute the slice's queue.
// Substring fallback is preserved for slices without typed-tag
// qitems (legacy corpus compatibility — HG-2).
it("when typed slice:<name> tag matches exist, mission-tagged-only qitems are NOT included", () => {
// Production shape: slices/missions root with a mission folder
// containing the slice. missionId resolves to the mission folder
// name; railItem defaults to missionId when frontmatter doesn't
// specify one. The over-match bug was about the missionId
// substring term polluting per-slice queue results.
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "release-fake", "slices"), "fake-slice-17", {
// Explicit rail-item disambiguates from missionId so the fix's
// "drop missionId, keep railItem" branch can be exercised
// without the railItem-equals-missionId collision.
"README.md": "---\nstatus: active\nrail-item: WALK-17\n---\n# Fake 17\n",
});
insertQitem(db, {
qitemId: "q-typed-slice-tag",
body: "Body without slice name.",
tags: ["slice:fake-slice-17"],
});
insertQitem(db, {
qitemId: "q-mission-tag-only",
body: "Body without slice name.",
tags: ["mission:release-fake"],
});
insertQitem(db, {
qitemId: "q-by-slice-name-body",
body: "fake-slice-17 mention in body.",
tags: [],
});
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
const slice = indexer.get("fake-slice-17")!;
expect(slice.qitemIds.sort()).toEqual(["q-by-slice-name-body", "q-typed-slice-tag"]);
expect(slice.qitemIds).not.toContain("q-mission-tag-only");
});
it("preserves legacy substring fallback (including mission body) when NO typed slice: tag exists", () => {
// When no qitem has the typed `slice:<name>` tag, the indexer
// falls back to the pre-fix three-term substring union so older
// dogfood corpora keep matching the way they did before this
// slice landed.
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "legacy-mission", "slices"), "legacy-slice", {
"README.md": "---\nstatus: active\nrail-item: LEGACY-RAIL\n---\n",
});
insertQitem(db, { qitemId: "q-legacy-by-mission", body: "advance the legacy-mission mission", tags: [] });
insertQitem(db, { qitemId: "q-legacy-by-name", body: "legacy-slice work item", tags: [] });
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, dogfoodEvidenceRoot: null, db });
const slice = indexer.get("legacy-slice")!;
// Both match: mission body via missionId substring, name body via sliceName substring.
expect(slice.qitemIds.sort()).toEqual(["q-legacy-by-mission", "q-legacy-by-name"]);
});
it("returns empty qitem set when queue_items table is absent", () => {
// Re-create db without queue_items to simulate the test-harness gap.
const bareDb = createDb();
@@ -319,4 +319,43 @@ describe("PL-slice-story-view-v0 slices routes", () => {
expect(res.headers.get("content-type")).toBe("image/png");
});
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (founder
// item 8 — Explorer auto-show). POST /api/slices/refresh drops both
// indexer caches so newly-created slice / mission folders show up
// without a daemon restart.
describe("POST /api/slices/refresh", () => {
it("returns 200 + drops the listing cache so freshly-created slices appear", async () => {
writeSlice(slicesRoot, "before-slice", { "README.md": "---\n---\n" });
// Prime the cache via a list call
const first = await app.request("/api/slices");
const firstList = (await first.json()) as { slices: Array<{ name: string }> };
expect(firstList.slices.map((s) => s.name)).toEqual(["before-slice"]);
// Add another slice on disk AFTER cache primed
writeSlice(slicesRoot, "after-slice", { "README.md": "---\n---\n" });
// Without refresh, the cached list should still be stale
const stale = await app.request("/api/slices");
const staleList = (await stale.json()) as { slices: Array<{ name: string }> };
expect(staleList.slices.map((s) => s.name).sort()).toEqual(["before-slice"]);
// Trigger refresh
const refresh = await app.request("/api/slices/refresh", { method: "POST" });
expect(refresh.status).toBe(200);
const refreshBody = (await refresh.json()) as { ok: boolean };
expect(refreshBody.ok).toBe(true);
// Now the list call picks up the new slice
const fresh = await app.request("/api/slices");
const freshList = (await fresh.json()) as { slices: Array<{ name: string }> };
expect(freshList.slices.map((s) => s.name).sort()).toEqual(["after-slice", "before-slice"]);
});
it("returns 503 when indexer is not wired", async () => {
const bareApp = new Hono();
bareApp.route("/api/slices", slicesRoutes());
const res = await bareApp.request("/api/slices/refresh", { method: "POST" });
expect(res.status).toBe(503);
});
});
});
@@ -9,8 +9,8 @@
import { useState, useMemo } from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useSlices, type SliceListEntry } from "../../hooks/useSlices.js";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { useSlices, useRefreshSlices, type SliceListEntry } from "../../hooks/useSlices.js";
import { useWorkspaceName } from "../../hooks/useWorkspaceName.js";
import {
useMissionDiscovery,
@@ -28,6 +28,31 @@ import {
type ProjectSliceRow,
} from "../../lib/project-mission-state.js";
function ProjectTreeRefreshHeader() {
const refresh = useRefreshSlices();
return (
<div className="flex items-center justify-between px-2 pb-2 border-b border-stone-200">
<span className="font-mono text-[9px] uppercase tracking-[0.16em] text-stone-500">
Project
</span>
<button
type="button"
data-testid="project-tree-refresh"
title="Refresh slice + file caches"
onClick={() => refresh.mutate()}
disabled={refresh.isPending}
className="flex items-center gap-1 px-1 py-0.5 text-on-surface-variant hover:text-stone-900 disabled:opacity-50"
>
<RefreshCw
className={`h-3 w-3 ${refresh.isPending ? "animate-spin" : ""}`}
aria-hidden="true"
/>
<span className="font-mono text-[9px] uppercase tracking-wide">refresh</span>
</button>
</div>
);
}
type GroupedMission = {
id: string;
label: string;
@@ -234,6 +259,17 @@ export function ProjectTreeView() {
return (
<div data-testid="project-tree-view" className="flex-1 overflow-y-auto py-2">
{/*
V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
item 8 (Explorer auto-show). Manual refresh button drops the
daemon-side indexer cache and react-query slice/file caches so
newly-created slice / mission folders appear without restarting
the daemon. Window-focus refetch in useSlices / useFilesList
handles the common "switched away to mkdir + came back" case;
this button is the explicit fallback when window-focus doesn't
fire (e.g., a fast operator who never blurs the tab).
*/}
<ProjectTreeRefreshHeader />
<ul>
<li data-testid="project-workspace-node">
<button
@@ -266,7 +266,20 @@ function ScopeQueueRollup({
queueItemsById: Map<string, QueueItemDetail>;
isFetching: boolean;
}) {
if (qitemIds.length === 0) {
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
// item 10 (slice queue descending order). Latest qitem at top. Prefer
// tsCreated when the loaded detail is available; fall back to the
// qitem-id (which encodes a timestamp prefix `qitem-YYYYMMDD...`) so
// sort works before queueItemsById has finished loading.
const sortedQitemIds = [...qitemIds].sort((a, b) => {
const itemA = queueItemsById.get(a);
const itemB = queueItemsById.get(b);
const tsA = itemA?.tsCreated ?? a;
const tsB = itemB?.tsCreated ?? b;
if (tsA === tsB) return 0;
return tsA < tsB ? 1 : -1; // DESC
});
if (sortedQitemIds.length === 0) {
return <EmptyState label="NO QITEMS" description="No queue items are indexed for this scope." variant="card" testId="scope-queue-empty" />;
}
return (
@@ -277,7 +290,7 @@ function ScopeQueueRollup({
</div>
) : null}
<ul className="divide-y divide-outline-variant border border-outline-variant">
{qitemIds.map((qitemId) => {
{sortedQitemIds.map((qitemId) => {
const item = queueItemsById.get(qitemId);
return (
<li key={qitemId} className="bg-white/35 backdrop-blur-sm">
+5
View File
@@ -73,6 +73,11 @@ export function useFilesList(root: string | null, path: string | null) {
queryFn: () => fetchList(root!, path ?? ""),
enabled: !!root,
staleTime: 15_000,
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
// item 8 (Explorer auto-show): refetch on window focus so new files
// / folders created while the operator was off-tab appear without a
// manual refresh click.
refetchOnWindowFocus: true,
});
}
+26 -1
View File
@@ -6,7 +6,7 @@
// instead of the raw 503.
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { useQueries, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export type SliceStatus = "active" | "done" | "blocked" | "draft";
export type SliceFilter = "all" | "active" | "done" | "blocked";
@@ -81,6 +81,31 @@ export function useSlices(filter: SliceFilter, boundToWorkflow: BoundToWorkflowF
],
queryFn: () => fetchSlicesList(filter, boundToWorkflow),
staleTime: 30_000,
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
// item 8 (Explorer auto-show): refetch on window focus so an operator
// who switches away to `mkdir slices/...` and comes back sees the
// new folder without manually clicking refresh.
refetchOnWindowFocus: true,
});
}
// V0.3.1 slice 17 founder-walk-workspace-state-correctness — founder
// item 8 (Explorer auto-show). Mutation hook for the Explorer header's
// manual refresh button: POSTs to /api/slices/refresh to drop the
// daemon-side indexer cache, then invalidates the react-query slices
// + files caches so the next render hits the fresh data.
export function useRefreshSlices() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/slices/refresh", { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { ok: boolean };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["slices"] });
queryClient.invalidateQueries({ queryKey: ["files"] });
},
});
}