tenbo-dashboard 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tenbo-dashboard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first architecture dashboard and CLI tools for .tenbo/ repos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@
45
45
  "@dnd-kit/core": "^6.3.1",
46
46
  "@dnd-kit/sortable": "^8.0.0",
47
47
  "@dnd-kit/utilities": "^3.2.2",
48
+ "@parcel/watcher": "^2.5.6",
48
49
  "@vitejs/plugin-react": "^4.3.1",
49
50
  "@xyflow/react": "^12.10.2",
50
51
  "chokidar": "^3.6.0",
package/src/App.tsx CHANGED
@@ -25,7 +25,7 @@ type Overlay =
25
25
  | null;
26
26
 
27
27
  export default function App() {
28
- const { state, loadError, reload, generation } = useTenboState();
28
+ const { state, loadError, reload, mergeItem, generation } = useTenboState();
29
29
  const patch = useApiPatch();
30
30
  const { route, navigate } = useHashRoute();
31
31
 
@@ -60,8 +60,12 @@ export default function App() {
60
60
  else navigate({ kind: 'docs-project', tab: 'overview' });
61
61
  };
62
62
 
63
+ // After PATCH the server returns the canonical item; merge into local
64
+ // state immediately rather than waiting for the SSE-triggered full reload
65
+ // (the SSE echo is filtered by origin token in useTenboState). td-005.
63
66
  const handlePatch = async (scopeId: string, itemId: string, p: Partial<Item>) => {
64
- await patch(scopeId, itemId, p);
67
+ const updated = await patch(scopeId, itemId, p);
68
+ mergeItem(scopeId, updated);
65
69
  };
66
70
 
67
71
  const body = (() => {
@@ -124,12 +128,20 @@ export default function App() {
124
128
  relatedDocs={related}
125
129
  state={state}
126
130
  onClose={() => setOverlay(null)}
127
- onPatch={async (p) => { await patch(overlay.scopeId, overlay.item.id, p); }}
131
+ onPatch={async (p) => {
132
+ const updated = await patch(overlay.scopeId, overlay.item.id, p);
133
+ mergeItem(overlay.scopeId, updated);
134
+ // Refresh the overlay's snapshot of the item so subsequent edits
135
+ // (and the "keep overlay in sync" effect above) see the latest.
136
+ setOverlay({ kind: 'modal', scopeId: overlay.scopeId, item: updated });
137
+ }}
128
138
  onOpenFile={(path) => { tenboApi.openFile(path).catch(() => {}); }}
129
139
  onPromoteRelated={async (path) => {
130
140
  const cur = overlay.item.links ?? [];
131
141
  if (!cur.includes(path)) {
132
- await patch(overlay.scopeId, overlay.item.id, { links: [...cur, path] });
142
+ const updated = await patch(overlay.scopeId, overlay.item.id, { links: [...cur, path] });
143
+ mergeItem(overlay.scopeId, updated);
144
+ setOverlay({ kind: 'modal', scopeId: overlay.scopeId, item: updated });
133
145
  }
134
146
  }}
135
147
  onSelectItem={(sId, it) => {
package/src/api/client.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import type { TenboState, RelatedDoc, Item, LayerDoc } from '../types';
2
2
 
3
+ /**
4
+ * Per-instance origin token. Sent with every PATCH so the server can attach it
5
+ * to the resulting SSE echo, which the SSE handler in useTenboState filters out
6
+ * (we already merged the change locally from the PATCH response — no need to
7
+ * trigger a redundant full reload). See td-005.
8
+ */
9
+ export const TENBO_ORIGIN = (() => {
10
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
11
+ return crypto.randomUUID();
12
+ }
13
+ return `o-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
14
+ })();
15
+
3
16
  async function http<T>(url: string, init?: RequestInit): Promise<T> {
4
17
  const r = await fetch(url, init);
5
18
  if (!r.ok) throw new Error(`${init?.method ?? 'GET'} ${url} -> ${r.status}`);
@@ -13,10 +26,13 @@ export const tenboApi = {
13
26
  getRelated(): Promise<RelatedDoc[]> {
14
27
  return http('/api/related');
15
28
  },
16
- patchItem(scopeId: string, itemId: string, patch: Partial<Item>): Promise<{ ok: true }> {
29
+ patchItem(scopeId: string, itemId: string, patch: Partial<Item>): Promise<{ ok: true; item: Item }> {
17
30
  return http(`/api/items/${itemId}`, {
18
31
  method: 'PATCH',
19
- headers: { 'Content-Type': 'application/json' },
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'X-Tenbo-Origin': TENBO_ORIGIN,
35
+ },
20
36
  body: JSON.stringify({ scope: scopeId, patch }),
21
37
  });
22
38
  },
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { getCached, invalidate, clearCache } from './parseCache';
6
+
7
+ describe('parseCache', () => {
8
+ let dir: string;
9
+ let file: string;
10
+ let parseCount: number;
11
+
12
+ beforeEach(() => {
13
+ dir = mkdtempSync(path.join(tmpdir(), 'tenbo-parsecache-'));
14
+ file = path.join(dir, 'sample.txt');
15
+ parseCount = 0;
16
+ clearCache();
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ function counter(s: string): { value: string } {
24
+ parseCount += 1;
25
+ return { value: s.trim() };
26
+ }
27
+
28
+ it('parses on first call, returns cached result on second', () => {
29
+ writeFileSync(file, 'hello');
30
+ const a = getCached(file, 'txt', counter);
31
+ const b = getCached(file, 'txt', counter);
32
+ expect(a.value).toBe('hello');
33
+ expect(b).toBe(a);
34
+ expect(parseCount).toBe(1);
35
+ });
36
+
37
+ it('re-parses when file content changes', () => {
38
+ writeFileSync(file, 'first');
39
+ const first = getCached(file, 'txt', counter);
40
+ expect(first.value).toBe('first');
41
+
42
+ // Force a future mtime so the fast-path re-checks the hash.
43
+ const future = Date.now() / 1000 + 5;
44
+ writeFileSync(file, 'second');
45
+ require('node:fs').utimesSync(file, future, future);
46
+
47
+ const second = getCached(file, 'txt', counter);
48
+ expect(second.value).toBe('second');
49
+ expect(parseCount).toBe(2);
50
+ });
51
+
52
+ it('keeps separate cache slots per signature', () => {
53
+ writeFileSync(file, 'shared');
54
+ getCached(file, 'parser-a', counter);
55
+ getCached(file, 'parser-b', counter);
56
+ expect(parseCount).toBe(2);
57
+ });
58
+
59
+ it('invalidate(path) drops cache entries for that path', () => {
60
+ writeFileSync(file, 'hello');
61
+ getCached(file, 'txt', counter);
62
+ invalidate(file);
63
+ getCached(file, 'txt', counter);
64
+ expect(parseCount).toBe(2);
65
+ });
66
+
67
+ it('serves stale on transient parse failure (does not poison cache)', () => {
68
+ writeFileSync(file, 'good');
69
+ const ok = getCached(file, 'txt', (s) => {
70
+ if (s === 'BAD') throw new Error('parse failed');
71
+ return { value: s };
72
+ });
73
+ expect(ok.value).toBe('good');
74
+
75
+ // Force a re-read by bumping mtime; parser will throw on the new content.
76
+ const future = Date.now() / 1000 + 5;
77
+ writeFileSync(file, 'BAD');
78
+ require('node:fs').utimesSync(file, future, future);
79
+
80
+ const stillOk = getCached(file, 'txt', (s) => {
81
+ if (s === 'BAD') throw new Error('parse failed');
82
+ return { value: s };
83
+ });
84
+ expect(stillOk.value).toBe('good'); // returned the previous good value
85
+ });
86
+
87
+ it('throws on first-ever parse failure (no previous good value to fall back to)', () => {
88
+ writeFileSync(file, 'BAD');
89
+ expect(() => {
90
+ getCached(file, 'txt', (s) => {
91
+ if (s === 'BAD') throw new Error('parse failed');
92
+ return { value: s };
93
+ });
94
+ }).toThrow('parse failed');
95
+ });
96
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * parseCache.ts — content-hash-keyed memoized file parser.
3
+ *
4
+ * Reads a file, hashes its bytes, and returns the parsed result. Subsequent
5
+ * calls for the same path with the same content return the cached parsed
6
+ * value without re-reading or re-parsing. When the content changes (any
7
+ * write through tenboFs, any external editor save), the next call detects
8
+ * the new hash, re-parses, and caches the new value.
9
+ *
10
+ * Why content hash, not just mtime: HFS+ has 1-second mtime resolution, so
11
+ * two writes within the same second look identical to mtime. Hashing the
12
+ * bytes is unambiguous.
13
+ *
14
+ * Partial-write race handling: a read that catches a YAML/JSON file
15
+ * mid-write may produce a parse error. The cache retains the previous good
16
+ * value on transient parse failure (the next read will succeed). Persistent
17
+ * failures bubble up to the caller on the SECOND attempt; the cache does
18
+ * not silently mask broken files indefinitely.
19
+ *
20
+ * The file watcher (`watch.ts`) also calls `invalidate(absPath)` on file
21
+ * change events so cache freshness doesn't depend on read frequency.
22
+ *
23
+ * Used by tenboFs.ts for the YAML reads that feed `readState` — by far the
24
+ * hottest read path. Other helpers (markdown narratives, layer content)
25
+ * could be added if perf measurement justifies it.
26
+ */
27
+
28
+ import { readFileSync, statSync } from 'node:fs';
29
+ import { createHash } from 'node:crypto';
30
+
31
+ interface CacheEntry<T> {
32
+ hash: string;
33
+ parsed: T;
34
+ /** mtime at the time of cache fill — used as a cheap pre-check to skip the hash. */
35
+ mtimeMs: number;
36
+ }
37
+
38
+ const cache = new Map<string, CacheEntry<unknown>>();
39
+
40
+ function safeMtime(absPath: string): number {
41
+ try { return statSync(absPath).mtimeMs; } catch { return -1; }
42
+ }
43
+
44
+ function hashBytes(buf: Buffer): string {
45
+ return createHash('sha1').update(buf).digest('hex');
46
+ }
47
+
48
+ /**
49
+ * Read+parse `absPath` with caching. `parser` is called only when the file's
50
+ * content has changed since the last successful parse.
51
+ *
52
+ * `signature` is a short string identifying the parser variant — different
53
+ * parsers (raw text vs YAML vs JSON) for the same path get separate cache
54
+ * slots so callers can use the cache for both shapes without collision.
55
+ */
56
+ export function getCached<T>(absPath: string, signature: string, parser: (text: string) => T): T {
57
+ const key = `${signature}::${absPath}`;
58
+ const mtime = safeMtime(absPath);
59
+
60
+ const existing = cache.get(key) as CacheEntry<T> | undefined;
61
+ // mtime fast-path: if the file's mtime hasn't moved AND we have a cached
62
+ // entry, we can skip re-reading. This is the common case during a single
63
+ // request that touches the same file twice.
64
+ if (existing && existing.mtimeMs === mtime && mtime !== -1) {
65
+ return existing.parsed;
66
+ }
67
+
68
+ // mtime changed (or no cache entry). Read + hash + maybe re-parse.
69
+ const buf = readFileSync(absPath);
70
+ const hash = hashBytes(buf);
71
+
72
+ if (existing && existing.hash === hash) {
73
+ // Content actually unchanged (mtime moved without content change — common
74
+ // when an editor "touches" a file or after `touch -m`). Refresh the
75
+ // mtime in the entry so the fast-path catches the next read.
76
+ existing.mtimeMs = mtime;
77
+ return existing.parsed;
78
+ }
79
+
80
+ // Re-parse. On parse error, retain previous good value (transient
81
+ // partial-write race). The caller can re-attempt; if it fails twice the
82
+ // exception propagates so we don't mask persistent corruption.
83
+ try {
84
+ const parsed = parser(buf.toString('utf8'));
85
+ cache.set(key, { hash, parsed, mtimeMs: mtime });
86
+ return parsed;
87
+ } catch (err) {
88
+ if (existing) return existing.parsed; // serve stale on transient failure
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ /** Drop a cached entry. Called by the file watcher on change events. */
94
+ export function invalidate(absPath: string): void {
95
+ for (const k of cache.keys()) {
96
+ if (k.endsWith(`::${absPath}`)) cache.delete(k);
97
+ }
98
+ }
99
+
100
+ /** For tests — clear everything. */
101
+ export function clearCache(): void {
102
+ cache.clear();
103
+ }
@@ -29,6 +29,20 @@ describe('derivePhaseStatus', () => {
29
29
  it('returns later for an empty list', () => {
30
30
  expect(derivePhaseStatus([])).toBe('later');
31
31
  });
32
+
33
+ it('returns dropped when every phase is dropped', () => {
34
+ expect(derivePhaseStatus([ph(1, 'dropped'), ph(2, 'dropped')])).toBe('dropped');
35
+ });
36
+
37
+ it('returns done when phases mix done and dropped (work shipped, rest abandoned)', () => {
38
+ expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'dropped')])).toBe('done');
39
+ expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'done'), ph(3, 'dropped')])).toBe('done');
40
+ });
41
+
42
+ it('still treats a now/next phase as active even if other phases are dropped', () => {
43
+ expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'now'), ph(3, 'dropped')])).toBe('now');
44
+ expect(derivePhaseStatus([ph(1, 'done'), ph(2, 'next'), ph(3, 'dropped')])).toBe('next');
45
+ });
32
46
  });
33
47
 
34
48
  describe('effectiveStatus', () => {
@@ -2,17 +2,23 @@
2
2
  * Shared helpers for the optional `phases:` field on roadmap items.
3
3
  *
4
4
  * Roll-up rule (single source of truth, mirrored in the validator and the viewer):
5
- * - `done` if every phase has status `done`
6
- * - `now` if any phase has status `now`
5
+ * - `dropped` if every phase has status `dropped`
6
+ * - `done` if every phase has status `done` OR every phase is in {done, dropped} and at least one is `done`
7
+ * - `now` if any phase has status `now`
7
8
  * - else `next` if any phase has status `next`
8
9
  * - else `later`
9
10
  *
11
+ * `dropped` phases are skipped during rollup (they don't count toward done/later)
12
+ * the way they don't count toward in-flight or pending — they're explicit
13
+ * acknowledgements that work won't be completed. An item where one phase shipped
14
+ * and one was dropped is conceptually `done`.
15
+ *
10
16
  * Items without `phases:` are unaffected — their explicit `status:` is the source
11
17
  * of truth.
12
18
  */
13
19
  import type { Item, Phase, Status } from '../../types';
14
20
 
15
- export const VALID_PHASE_STATUSES: ReadonlySet<Status> = new Set(['now', 'next', 'later', 'done']);
21
+ export const VALID_PHASE_STATUSES: ReadonlySet<Status> = new Set(['now', 'next', 'later', 'done', 'dropped']);
16
22
 
17
23
  /** YYYY-MM-DD shape check. Does not validate calendar correctness. */
18
24
  export function isIsoDate(s: string): boolean {
@@ -22,7 +28,8 @@ export function isIsoDate(s: string): boolean {
22
28
  /** Compute a derived item status from a non-empty phase list. */
23
29
  export function derivePhaseStatus(phases: Phase[]): Status {
24
30
  if (phases.length === 0) return 'later';
25
- if (phases.every((p) => p.status === 'done')) return 'done';
31
+ if (phases.every((p) => p.status === 'dropped')) return 'dropped';
32
+ if (phases.every((p) => p.status === 'done' || p.status === 'dropped')) return 'done';
26
33
  if (phases.some((p) => p.status === 'now')) return 'now';
27
34
  if (phases.some((p) => p.status === 'next')) return 'next';
28
35
  return 'later';
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sortByPriority, comparePriority } from './priority';
3
+ import type { Item, Priority } from '../../types';
4
+
5
+ const item = (id: string, priority?: Priority): Item =>
6
+ ({ id, title: id, status: 'now', description: '' } as Item) as any && (
7
+ { id, title: id, status: 'now', description: '', priority } as unknown as Item
8
+ );
9
+
10
+ describe('sortByPriority', () => {
11
+ it('orders p0 → p1 → p2 → p3 → unset', () => {
12
+ const input = [
13
+ item('a', 'p3'),
14
+ item('b'),
15
+ item('c', 'p0'),
16
+ item('d', 'p2'),
17
+ item('e', 'p1'),
18
+ ];
19
+ const sorted = sortByPriority(input).map(i => i.id);
20
+ expect(sorted).toEqual(['c', 'e', 'd', 'a', 'b']);
21
+ });
22
+
23
+ it('preserves file order on ties (stable sort)', () => {
24
+ const input = [
25
+ item('first', 'p2'),
26
+ item('second', 'p2'),
27
+ item('third', 'p2'),
28
+ item('zeroth', 'p0'),
29
+ ];
30
+ const sorted = sortByPriority(input).map(i => i.id);
31
+ expect(sorted).toEqual(['zeroth', 'first', 'second', 'third']);
32
+ });
33
+
34
+ it('returns a new array (does not mutate input)', () => {
35
+ const input = [item('a', 'p3'), item('b', 'p0')];
36
+ const inputCopy = [...input];
37
+ sortByPriority(input);
38
+ expect(input).toEqual(inputCopy);
39
+ });
40
+ });
41
+
42
+ describe('comparePriority', () => {
43
+ it('returns negative when first has higher priority', () => {
44
+ expect(comparePriority(item('a', 'p0'), item('b', 'p3'))).toBeLessThan(0);
45
+ });
46
+ it('returns positive when second has higher priority', () => {
47
+ expect(comparePriority(item('a', 'p3'), item('b', 'p0'))).toBeGreaterThan(0);
48
+ });
49
+ it('returns 0 on equal priority (file-order tiebreaker)', () => {
50
+ expect(comparePriority(item('a', 'p2'), item('b', 'p2'))).toBe(0);
51
+ });
52
+ it('treats unset as the lowest priority', () => {
53
+ expect(comparePriority(item('a'), item('b', 'p3'))).toBeGreaterThan(0);
54
+ expect(comparePriority(item('a', 'p0'), item('b'))).toBeLessThan(0);
55
+ });
56
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * priority.ts — sort comparator for roadmap items by priority.
3
+ *
4
+ * Order: p0 (highest) → p1 → p2 → p3 → unset (last). Equal priorities keep
5
+ * their original file-order via Array.sort's ES2019 stable-sort guarantee.
6
+ *
7
+ * Used by both KanbanColumn and TaskList so the kanban + list views share
8
+ * the same priority-first ordering. (td-009)
9
+ *
10
+ * Note: drag-reorder still modifies roadmap.yaml file order; the priority
11
+ * sort applies as a presentational layer on top. Items with identical
12
+ * priority will follow whatever file order the user dragged them into.
13
+ */
14
+
15
+ import type { Item, Priority } from '../../types';
16
+
17
+ const RANK: Record<Priority | 'unset', number> = {
18
+ p0: 0,
19
+ p1: 1,
20
+ p2: 2,
21
+ p3: 3,
22
+ unset: 4,
23
+ };
24
+
25
+ function rank(item: Item): number {
26
+ return RANK[item.priority ?? 'unset'];
27
+ }
28
+
29
+ /** Compare two items by priority (lower rank wins). Stable on equal priorities. */
30
+ export function comparePriority(a: Item, b: Item): number {
31
+ return rank(a) - rank(b);
32
+ }
33
+
34
+ /** Return a copy of `items` sorted by priority (file order preserved on ties). */
35
+ export function sortByPriority<T extends Item>(items: T[]): T[] {
36
+ return [...items].sort(comparePriority);
37
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * recentWrites.ts — short-lived registry of writes the server just made,
3
+ * keyed by absolute file path with their originating client token.
4
+ *
5
+ * Used for SSE echo suppression (td-005): when a chokidar event fires for
6
+ * a path the server just wrote, the watch route checks this registry; if
7
+ * the path matches a recent write, the SSE payload includes the originating
8
+ * client's token. Clients then filter out events carrying their own token,
9
+ * since they've already merged the change locally from the PATCH response.
10
+ *
11
+ * Entries auto-expire after RECENT_WRITE_TTL_MS to bound memory and avoid
12
+ * stale matches against later external writes to the same path.
13
+ */
14
+
15
+ const RECENT_WRITE_TTL_MS = 1000;
16
+
17
+ interface RecentWrite {
18
+ origin: string | null;
19
+ ts: number;
20
+ }
21
+
22
+ const recent = new Map<string, RecentWrite>();
23
+
24
+ /** Record a write to `absPath` originating from `origin` (null = unknown source). */
25
+ export function recordRecentWrite(absPath: string, origin: string | null): void {
26
+ recent.set(absPath, { origin, ts: Date.now() });
27
+ // Schedule cleanup so the map doesn't grow unbounded across a long session.
28
+ setTimeout(() => {
29
+ const cur = recent.get(absPath);
30
+ if (cur && Date.now() - cur.ts >= RECENT_WRITE_TTL_MS) {
31
+ recent.delete(absPath);
32
+ }
33
+ }, RECENT_WRITE_TTL_MS + 50);
34
+ }
35
+
36
+ /**
37
+ * If `absPath` was written within the TTL window by a known origin, return
38
+ * that origin token. Otherwise null (treat as external/unknown change).
39
+ */
40
+ export function getRecentOrigin(absPath: string): string | null {
41
+ const cur = recent.get(absPath);
42
+ if (!cur) return null;
43
+ if (Date.now() - cur.ts >= RECENT_WRITE_TTL_MS) {
44
+ recent.delete(absPath);
45
+ return null;
46
+ }
47
+ return cur.origin;
48
+ }
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, renameSync, statS
2
2
  import path from 'node:path';
3
3
  import { parse as parseSimple } from 'yaml';
4
4
  import { parseYaml, stringifyYaml, patchSeqItem, reorderSeqItems } from './yamlOrdered';
5
+ import { getCached, invalidate as invalidateCache } from './parseCache';
5
6
  import type { TenboState, Scope, Item, Layer, CrossCutting, LayerDocs, ScopeMetrics, WorkspaceContent, LayerContent } from '../../types';
6
7
 
7
8
  function tenboDir(repoRoot: string) { return path.join(repoRoot, '.tenbo'); }
@@ -47,8 +48,10 @@ export function tenboExists(repoRoot: string): boolean {
47
48
  }
48
49
 
49
50
  export function readWorkspace(repoRoot: string): { scopeRefs: { id: string; path: string; description: string }[]; crossCutting: CrossCutting[] } {
50
- const text = readFileSync(path.join(tenboDir(repoRoot), 'workspace.yaml'), 'utf8');
51
- const data = parseSimple(text) as any;
51
+ // Cached parse re-reads only when workspace.yaml content changes.
52
+ // See parseCache.ts for the content-hash + transient-failure semantics.
53
+ const file = path.join(tenboDir(repoRoot), 'workspace.yaml');
54
+ const data = getCached(file, 'workspace-yaml', (text) => parseSimple(text) as any);
52
55
  return {
53
56
  scopeRefs: data?.scopes ?? [],
54
57
  crossCutting: data?.cross_cutting ?? [],
@@ -56,14 +59,14 @@ export function readWorkspace(repoRoot: string): { scopeRefs: { id: string; path
56
59
  }
57
60
 
58
61
  function readArchitecture(repoRoot: string, scopeId: string): Layer[] {
59
- const text = readFileSync(path.join(tenboDir(repoRoot), 'scopes', scopeId, 'architecture.yaml'), 'utf8');
60
- const data = parseSimple(text) as any;
62
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'architecture.yaml');
63
+ const data = getCached(file, 'architecture-yaml', (text) => parseSimple(text) as any);
61
64
  return data?.layers ?? [];
62
65
  }
63
66
 
64
67
  function readRoadmap(repoRoot: string, scopeId: string): Item[] {
65
- const text = readFileSync(path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml'), 'utf8');
66
- const data = parseSimple(text) as any;
68
+ const file = path.join(tenboDir(repoRoot), 'scopes', scopeId, 'roadmap.yaml');
69
+ const data = getCached(file, 'roadmap-yaml', (text) => parseSimple(text) as any);
67
70
  return data?.items ?? [];
68
71
  }
69
72
 
@@ -218,6 +221,10 @@ function atomicWrite(filePath: string, content: string): void {
218
221
  const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
219
222
  writeFileSync(tmp, content, 'utf8');
220
223
  renameSync(tmp, filePath);
224
+ // Drop any cached parse for this path. The next read will re-parse from
225
+ // disk; the file watcher's invalidate will cover external edits, this
226
+ // covers our own writes (no race with the SSE event arriving "later").
227
+ invalidateCache(filePath);
221
228
  }
222
229
 
223
230
  export function patchItem(repoRoot: string, scopeId: string, itemId: string, patch: Partial<Item>): void {
@@ -8,7 +8,7 @@ const FORBIDDEN_JARGON = [
8
8
  'worker', 'daemon',
9
9
  ];
10
10
 
11
- const VALID_STATUSES = new Set(['now', 'next', 'later', 'done']);
11
+ const VALID_STATUSES = new Set(['now', 'next', 'later', 'done', 'dropped']);
12
12
  const VALID_PRIORITIES = new Set(['p0', 'p1', 'p2', 'p3']);
13
13
  const ITEM_ID_RE = /^[a-z]{1,5}-\d{3,}$/;
14
14
  const DOC_UPDATE_REQUIRED_TYPES = new Set(['feature', 'refactor', 'bug']);
package/src/api/plugin.ts CHANGED
@@ -23,7 +23,9 @@ export function tenboApiPlugin(): Plugin {
23
23
  server.middlewares.use(reorderRoute(repoRoot));
24
24
  server.middlewares.use(relatedRoute(repoRoot));
25
25
  server.middlewares.use(openRoute(repoRoot));
26
- server.middlewares.use(watchRoute(repoRoot));
26
+ const watch = watchRoute(repoRoot);
27
+ server.middlewares.use(watch.handler);
28
+ server.httpServer?.on('close', () => { void watch.close(); });
27
29
  server.middlewares.use(layerDocsRoute(repoRoot));
28
30
  server.middlewares.use(layerContentRoute(repoRoot));
29
31
  },
@@ -1,8 +1,10 @@
1
1
  import type { Connect } from 'vite';
2
- import { patchItem } from '../lib/tenboFs';
2
+ import path from 'node:path';
3
+ import { patchItem, readState } from '../lib/tenboFs';
4
+ import { recordRecentWrite } from '../lib/recentWrites';
3
5
  import { readBody, json, error, withErrorHandling } from '../lib/http';
4
6
 
5
- const ALLOWED = new Set(['title', 'description', 'status', 'layer', 'notes', 'links', 'priority', 'done_when', 'files_to_read', 'risks']);
7
+ const ALLOWED = new Set(['title', 'description', 'status', 'layer', 'notes', 'links', 'priority', 'done_when', 'files_to_read', 'risks', 'phases']);
6
8
 
7
9
  export function itemsRoute(repoRoot: string): Connect.NextHandleFunction {
8
10
  return withErrorHandling(async (req, res, next) => {
@@ -19,7 +21,26 @@ export function itemsRoute(repoRoot: string): Connect.NextHandleFunction {
19
21
  for (const k of Object.keys(patch)) {
20
22
  if (ALLOWED.has(k)) filtered[k] = patch[k];
21
23
  }
24
+
25
+ // Record the originating client token so the SSE channel can suppress
26
+ // the echo back to that client (td-005 step 3 — echo suppression).
27
+ // Clients send X-Tenbo-Origin as a per-instance UUID generated at boot.
28
+ const origin = (req.headers['x-tenbo-origin'] as string | undefined) ?? null;
29
+ const filePath = path.join(repoRoot, '.tenbo', 'scopes', scopeId, 'roadmap.yaml');
30
+ recordRecentWrite(filePath, origin);
31
+
22
32
  patchItem(repoRoot, scopeId, itemId, filtered);
23
- json(res, { ok: true });
33
+
34
+ // Re-read state and return the canonical post-write item so the client
35
+ // can merge into its local state immediately (td-005 step 1+2 —
36
+ // optimistic UI without waiting for SSE). Falls through to a 404 if
37
+ // the item somehow disappeared during the write (concurrent delete).
38
+ const state = readState(repoRoot);
39
+ const scope = state.scopes.find((s) => s.id === scopeId);
40
+ const item = scope?.items.find((i) => i.id === itemId);
41
+ if (!item) {
42
+ return error(res, 404, `item ${itemId} not found in scope ${scopeId} after patch`);
43
+ }
44
+ json(res, { ok: true, item });
24
45
  });
25
46
  }
@@ -1,24 +1,154 @@
1
1
  import type { Connect } from 'vite';
2
- import chokidar from 'chokidar';
2
+ import watcher, { type AsyncSubscription, type Event as WatcherEvent } from '@parcel/watcher';
3
3
  import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { getRecentOrigin } from '../lib/recentWrites';
7
+ import { invalidate as invalidateParseCache } from '../lib/parseCache';
8
+ import type { Item } from '../../types';
4
9
 
5
- export function watchRoute(repoRoot: string): Connect.NextHandleFunction {
6
- // One watcher per process; multiple SSE clients share it.
7
- const watchPath = path.join(repoRoot, '.tenbo');
10
+ /**
11
+ * SSE channel server-pushed events the dashboard subscribes to.
12
+ *
13
+ * Two event kinds (td-006, item-level events — Q2=b):
14
+ *
15
+ * {kind: 'roadmap-change', scopeId, items, origin?}
16
+ * Fires when a `.tenbo/scopes/<scope>/roadmap.yaml` changes. The full
17
+ * post-change items list for that scope is included in the payload —
18
+ * the client replaces its local items[] for that scope without a full
19
+ * state refetch. Lightest payload that still scales beyond ~100 items.
20
+ *
21
+ * {kind: 'file-change', event, path, origin?}
22
+ * Fires for non-roadmap changes (architecture.yaml, narratives,
23
+ * workspace files, principles.md, glossary.md, /docs/superpowers/, etc).
24
+ * Client triggers its existing debounced full reload — these are rare
25
+ * and structural; per-file diffs aren't worth the protocol surface yet.
26
+ *
27
+ * Both event types include `origin` when the write was made by a known
28
+ * client (set via X-Tenbo-Origin header on the originating PATCH); the
29
+ * client filters echoes of its own writes — see useTenboState.
30
+ *
31
+ * File watching uses @parcel/watcher (native FSEvents/inotify directly) —
32
+ * faster and more reliable than chokidar at scale, with proper atomic-write
33
+ * handling. Editor swap files are filtered via the ignore globs below.
34
+ */
35
+
36
+ // Editor swap-file patterns that should never trigger SSE events.
37
+ const IGNORE_GLOBS = [
38
+ '**/*.swp', // vim
39
+ '**/*.swo', // vim
40
+ '**/*.swx', // vim
41
+ '**/*~', // many editors' backup
42
+ '**/4913', // vim's pre-write probe
43
+ '**/.DS_Store', // macOS
44
+ '**/.git/**',
45
+ '**/node_modules/**',
46
+ ];
47
+
48
+ interface RoadmapSnapshot {
49
+ // Per-scope items snapshot keyed by absolute roadmap.yaml path. Used to
50
+ // serve the latest items[] in `roadmap-change` events without re-reading
51
+ // on every SSE client subscribe.
52
+ itemsByPath: Map<string, Item[]>;
53
+ }
54
+
55
+ function isRoadmapFile(absPath: string, repoRoot: string): { isRoadmap: true; scopeId: string } | { isRoadmap: false } {
56
+ const tenboScopes = path.join(repoRoot, '.tenbo', 'scopes');
57
+ if (!absPath.startsWith(tenboScopes + path.sep)) return { isRoadmap: false };
58
+ if (path.basename(absPath) !== 'roadmap.yaml') return { isRoadmap: false };
59
+ const rel = absPath.slice(tenboScopes.length + 1);
60
+ const parts = rel.split(path.sep);
61
+ if (parts.length !== 2) return { isRoadmap: false }; // expect <scope>/roadmap.yaml
62
+ return { isRoadmap: true, scopeId: parts[0] };
63
+ }
64
+
65
+ function readRoadmapItems(absPath: string): Item[] {
66
+ try {
67
+ const text = fs.readFileSync(absPath, 'utf8');
68
+ const doc = parseYaml(text);
69
+ const items = (doc?.items as Item[]) ?? [];
70
+ return items;
71
+ } catch {
72
+ // Partial-write race or YAML error — skip emitting; client falls back
73
+ // to debounced full reload via the file-change path.
74
+ return [];
75
+ }
76
+ }
77
+
78
+ export function watchRoute(repoRoot: string): { handler: Connect.NextHandleFunction; close: () => Promise<void> } {
79
+ const tenboPath = path.join(repoRoot, '.tenbo');
8
80
  const docsPath = path.join(repoRoot, 'docs/superpowers');
9
- const watcher = chokidar.watch([watchPath, docsPath], {
10
- ignoreInitial: true,
11
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
12
- });
81
+
82
+ const snapshot: RoadmapSnapshot = { itemsByPath: new Map() };
13
83
 
14
84
  type Client = { write: (data: string) => void; end: () => void };
15
85
  const clients = new Set<Client>();
16
- watcher.on('all', (event, file) => {
17
- const payload = `data: ${JSON.stringify({ event, path: path.relative(repoRoot, file) })}\n\n`;
18
- for (const c of clients) c.write(payload);
19
- });
20
86
 
21
- return (req, res, next) => {
87
+ function broadcast(payload: object) {
88
+ const line = `data: ${JSON.stringify(payload)}\n\n`;
89
+ for (const c of clients) c.write(line);
90
+ }
91
+
92
+ function emitRoadmapChange(absPath: string, scopeId: string, originHint: string | null) {
93
+ const items = readRoadmapItems(absPath);
94
+ snapshot.itemsByPath.set(absPath, items);
95
+ broadcast({
96
+ kind: 'roadmap-change',
97
+ scopeId,
98
+ items,
99
+ origin: originHint,
100
+ });
101
+ }
102
+
103
+ function emitFileChange(absPath: string, type: WatcherEvent['type'], originHint: string | null) {
104
+ broadcast({
105
+ kind: 'file-change',
106
+ event: type, // 'create' | 'update' | 'delete'
107
+ path: path.relative(repoRoot, absPath),
108
+ origin: originHint,
109
+ });
110
+ }
111
+
112
+ function handleEvent(absPath: string, type: WatcherEvent['type']) {
113
+ // External writes (and our own atomic-rename writes) invalidate the
114
+ // parsed-file cache so the next read picks up the change.
115
+ invalidateParseCache(absPath);
116
+ const origin = getRecentOrigin(absPath);
117
+ const cls = isRoadmapFile(absPath, repoRoot);
118
+ if (cls.isRoadmap) {
119
+ // For deletions, the file may be gone; still emit so the client knows
120
+ // to clear (or the next full reload picks it up).
121
+ if (type === 'delete') {
122
+ snapshot.itemsByPath.delete(absPath);
123
+ broadcast({ kind: 'roadmap-change', scopeId: cls.scopeId, items: [], origin });
124
+ return;
125
+ }
126
+ emitRoadmapChange(absPath, cls.scopeId, origin);
127
+ } else {
128
+ emitFileChange(absPath, type, origin);
129
+ }
130
+ }
131
+
132
+ // Subscribe to each watch root. @parcel/watcher takes one path per call.
133
+ const subscriptions: AsyncSubscription[] = [];
134
+ async function subscribeIfExists(p: string) {
135
+ try {
136
+ if (!fs.existsSync(p)) return;
137
+ const sub = await watcher.subscribe(p, (err, events) => {
138
+ if (err) return; // best-effort — file watcher errors shouldn't crash the dev server
139
+ for (const ev of events) handleEvent(ev.path, ev.type);
140
+ }, { ignore: IGNORE_GLOBS });
141
+ subscriptions.push(sub);
142
+ } catch {
143
+ // Native binary not available for this platform / permissions — degrade gracefully.
144
+ // (Calls fall through; SSE just won't fire on file changes.)
145
+ }
146
+ }
147
+ // Fire-and-forget; subscribe happens async at startup.
148
+ void subscribeIfExists(tenboPath);
149
+ void subscribeIfExists(docsPath);
150
+
151
+ const handler: Connect.NextHandleFunction = (req, res, next) => {
22
152
  if (req.method !== 'GET' || req.url !== '/api/watch') return next();
23
153
  res.setHeader('Content-Type', 'text/event-stream');
24
154
  res.setHeader('Cache-Control', 'no-cache');
@@ -28,4 +158,10 @@ export function watchRoute(repoRoot: string): Connect.NextHandleFunction {
28
158
  clients.add(client);
29
159
  req.on('close', () => clients.delete(client));
30
160
  };
161
+
162
+ async function close() {
163
+ await Promise.all(subscriptions.map((s) => s.unsubscribe().catch(() => {})));
164
+ }
165
+
166
+ return { handler, close };
31
167
  }
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, useCallback, useRef } from 'react';
2
- import type { TenboState } from '../types';
3
- import { tenboApi } from '../api/client';
2
+ import type { TenboState, Item } from '../types';
3
+ import { tenboApi, TENBO_ORIGIN } from '../api/client';
4
4
 
5
5
  export function useTenboState() {
6
6
  const [state, setState] = useState<TenboState | null>(null);
@@ -18,18 +18,79 @@ export function useTenboState() {
18
18
  }
19
19
  }, []);
20
20
 
21
+ /**
22
+ * Merge a single canonical item into local state without a full reload.
23
+ * Used after a PATCH response — the server has already written, returned
24
+ * the canonical item shape, and our SSE handler will skip the echo for
25
+ * this client's own origin token. The visible state updates within a
26
+ * single React frame instead of waiting ~250ms for the SSE round-trip.
27
+ * See td-005.
28
+ */
29
+ const mergeItem = useCallback((scopeId: string, item: Item) => {
30
+ setState(prev => {
31
+ if (!prev) return prev;
32
+ return {
33
+ ...prev,
34
+ scopes: prev.scopes.map(s =>
35
+ s.id !== scopeId
36
+ ? s
37
+ : { ...s, items: s.items.map(i => (i.id === item.id ? item : i)) },
38
+ ),
39
+ };
40
+ });
41
+ // Bump generation so memoized derivations invalidate (kanban groupings,
42
+ // priority sort, etc.) just as they would after a full reload.
43
+ setGeneration(n => n + 1);
44
+ }, []);
45
+
21
46
  useEffect(() => { reload(); }, [reload]);
22
47
 
23
- // SSE: debounced reload on any file change
48
+ /**
49
+ * Replace all items for a single scope without a full state reload.
50
+ * Used when SSE delivers a `roadmap-change` event (td-006 — item-level
51
+ * events) carrying the new items list. The rest of the state graph
52
+ * (scopes, layers, narratives, metrics) is unchanged for roadmap edits,
53
+ * so we don't refetch any of it.
54
+ */
55
+ const replaceScopeItems = useCallback((scopeId: string, items: Item[]) => {
56
+ setState(prev => {
57
+ if (!prev) return prev;
58
+ return {
59
+ ...prev,
60
+ scopes: prev.scopes.map(s =>
61
+ s.id !== scopeId ? s : { ...s, items },
62
+ ),
63
+ };
64
+ });
65
+ setGeneration(n => n + 1);
66
+ }, []);
67
+
68
+ // SSE handler. Three event kinds, in increasing cost:
69
+ // - `roadmap-change`: replace one scope's items[]; no fetch.
70
+ // - `file-change` for non-roadmap files: debounced full reload (rare).
71
+ // - Echo of our own write (origin matches): ignored entirely.
24
72
  useEffect(() => {
25
73
  const es = new EventSource('/api/watch');
26
- es.onmessage = () => {
74
+ es.onmessage = (e) => {
75
+ let data: { kind?: string; scopeId?: string; items?: Item[]; origin?: string | null } | null = null;
76
+ try {
77
+ data = JSON.parse(e.data);
78
+ } catch { /* malformed — fall through and reload as a safety net */ }
79
+
80
+ if (data?.origin && data.origin === TENBO_ORIGIN) return; // own write — already merged
81
+
82
+ if (data?.kind === 'roadmap-change' && data.scopeId && Array.isArray(data.items)) {
83
+ replaceScopeItems(data.scopeId, data.items);
84
+ return;
85
+ }
86
+
87
+ // file-change (non-roadmap) or unknown shape: fall back to debounced full reload.
27
88
  if (reloadDebounce.current) window.clearTimeout(reloadDebounce.current);
28
89
  reloadDebounce.current = window.setTimeout(() => { reload(); }, 250);
29
90
  };
30
91
  es.onerror = () => { /* browser auto-reconnects */ };
31
92
  return () => es.close();
32
- }, [reload]);
93
+ }, [reload, replaceScopeItems]);
33
94
 
34
- return { state, loadError, reload, generation };
95
+ return { state, loadError, reload, mergeItem, generation };
35
96
  }
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@ import type { Finding } from './api/lib/health/types';
6
6
  // - camelCase fields are in-memory composed state assembled by the viewer (e.g., `crossCuttingRoadmap`,
7
7
  // `layerDocs`). Renaming these is purely an internal refactor.
8
8
 
9
- export type Status = 'now' | 'next' | 'later' | 'done';
9
+ export type Status = 'now' | 'next' | 'later' | 'done' | 'dropped';
10
10
 
11
11
  /**
12
12
  * Advisory priority marker. Does NOT affect roadmap ordering — items still execute
@@ -5,9 +5,21 @@
5
5
  margin-bottom: 4px;
6
6
  cursor: grab;
7
7
  position: relative;
8
- transition: background var(--dur-state) var(--ease);
8
+ transition: background var(--dur-state) var(--ease), opacity var(--dur-state) var(--ease);
9
9
  }
10
10
  .card:hover { background: var(--console-mist); }
11
+
12
+ /*
13
+ * Done cards fade to 60% so live columns (now/next/later) dominate the
14
+ * visual hierarchy. Hover lifts to full opacity as a subtle affordance
15
+ * that the card is still interactive. (td-008)
16
+ */
17
+ .cardDone {
18
+ opacity: 0.6;
19
+ }
20
+ .cardDone:hover {
21
+ opacity: 1;
22
+ }
11
23
  .card:focus-visible {
12
24
  outline: 2px solid var(--signal-cyan);
13
25
  outline-offset: 2px;
@@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable';
3
3
  import { CSS } from '@dnd-kit/utilities';
4
4
  import { CornerLeftUp, ArrowLeftRight } from 'lucide-react';
5
5
  import type { Item } from '../types';
6
- import { phaseProgress } from '../api/lib/phases';
6
+ import { effectiveStatus, phaseProgress } from '../api/lib/phases';
7
7
  import styles from './ItemCard.module.css';
8
8
 
9
9
  interface Props {
@@ -24,10 +24,15 @@ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
24
24
  const phases = item.phases ?? [];
25
25
  const progress = phases.length > 0 ? phaseProgress(phases) : null;
26
26
 
27
+ // Done cards are dimmed in the kanban so the live columns (now/next/later)
28
+ // own the user's visual attention. Hover restores opacity. The item modal
29
+ // (rendered separately) is not affected — only the kanban card. (td-008)
30
+ const isDone = effectiveStatus(item) === 'done';
31
+
27
32
  return (
28
33
  <div
29
34
  ref={setNodeRef}
30
- className={styles.card}
35
+ className={`${styles.card}${isDone ? ` ${styles.cardDone}` : ''}`}
31
36
  style={dragStyle}
32
37
  {...attributes}
33
38
  {...listeners}
@@ -1,7 +1,7 @@
1
1
  import type { Item, Phase, Status } from '../../types';
2
2
  import { phaseProgress } from '../../api/lib/phases';
3
3
 
4
- const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
4
+ const STATUSES: Status[] = ['now', 'next', 'later', 'done', 'dropped'];
5
5
 
6
6
  interface Props {
7
7
  item: Item;
@@ -1,7 +1,7 @@
1
1
  import type { Layer, Priority, Status } from '../../types';
2
2
  import styles from './ItemModal.module.css';
3
3
 
4
- const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
4
+ const STATUSES: Status[] = ['now', 'next', 'later', 'done', 'dropped'];
5
5
  const PRIORITIES: Priority[] = ['p0', 'p1', 'p2', 'p3'];
6
6
 
7
7
  interface Props {
@@ -2,6 +2,7 @@ import { useDroppable } from '@dnd-kit/core';
2
2
  import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
3
3
  import { ItemCard } from './ItemCard';
4
4
  import type { Item, Status } from '../types';
5
+ import { sortByPriority } from '../api/lib/priority';
5
6
  import styles from './KanbanColumn.module.css';
6
7
 
7
8
  interface Props {
@@ -13,15 +14,19 @@ interface Props {
13
14
  onDescEdit: (id: string, desc: string) => void;
14
15
  }
15
16
 
16
- const LABEL: Record<Status, string> = { now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' };
17
+ const LABEL: Record<Status, string> = { now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE', dropped: 'DROPPED' };
17
18
 
18
19
  export function KanbanColumn({ layerId, status, items, onCardClick, onTitleEdit, onDescEdit }: Props) {
19
20
  const { setNodeRef, isOver } = useDroppable({ id: `${layerId}::${status}`, data: { layerId, status } });
21
+ // Sort within the column by priority (p0→p3→unset). Stable on equal
22
+ // priorities — file order from roadmap.yaml acts as the secondary key,
23
+ // so drag-reorder still has visible effect within a priority bucket. (td-009)
24
+ const sorted = sortByPriority(items);
20
25
  return (
21
26
  <div ref={setNodeRef} className={`${styles.column} ${isOver ? styles.columnOver : ''}`}>
22
27
  <div className={styles.label}>{LABEL[status]}</div>
23
- <SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
24
- {items.map(item => (
28
+ <SortableContext items={sorted.map(i => i.id)} strategy={verticalListSortingStrategy}>
29
+ {sorted.map(item => (
25
30
  <ItemCard
26
31
  key={item.id}
27
32
  item={item}
@@ -18,7 +18,11 @@ interface Props {
18
18
 
19
19
  const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
20
20
 
21
- export function LayerKanban({ layer, items, depth = 0, alwaysOpen = false, onCardClick, onTitleEdit, onDescEdit }: Props) {
21
+ export function LayerKanban({ layer, items: rawItems, depth = 0, alwaysOpen = false, onCardClick, onTitleEdit, onDescEdit }: Props) {
22
+ // `dropped` items are hidden from the kanban by default — they're still
23
+ // editable via the item modal, still counted by validators, but they don't
24
+ // clutter the visual board. (Future: a "show dropped" toggle.)
25
+ const items = rawItems.filter(i => effectiveStatus(i) !== 'dropped');
22
26
  const counts = STATUSES.map(s => items.filter(i => effectiveStatus(i) === s).length);
23
27
  const hasActive = counts[0] > 0 || counts[1] > 0;
24
28
  const [open, setOpen] = useState(hasActive);
@@ -1,6 +1,7 @@
1
1
  import { CornerLeftUp, ArrowLeftRight } from 'lucide-react';
2
2
  import type { Item, Status } from '../types';
3
3
  import { effectiveStatus } from '../api/lib/phases';
4
+ import { comparePriority } from '../api/lib/priority';
4
5
  import styles from './TaskList.module.css';
5
6
 
6
7
  export interface FlatItem {
@@ -15,11 +16,18 @@ interface Props {
15
16
  }
16
17
 
17
18
  const STATUS_ORDER: Status[] = ['now', 'next', 'later', 'done'];
18
- const STATUS_LABEL: Record<Status, string> = { now: 'Now', next: 'Next', later: 'Later', done: 'Done' };
19
+ const STATUS_LABEL: Record<Status, string> = { now: 'Now', next: 'Next', later: 'Later', done: 'Done', dropped: 'Dropped' };
19
20
 
20
- export function TaskList({ items, onRowClick }: Props) {
21
+ export function TaskList({ items: rawItems, onRowClick }: Props) {
22
+ // Hide `dropped` items from the list view (matches LayerKanban behavior).
23
+ const items = rawItems.filter(({ item }) => effectiveStatus(item) !== 'dropped');
24
+ // Group by status (now → next → later → done) and within each status
25
+ // group by priority (p0 → p3 → unset). Both sorts are stable so file
26
+ // order acts as the tiebreaker for items at the same priority. (td-009)
21
27
  const sorted = STATUS_ORDER.flatMap(s =>
22
- items.filter(({ item }) => effectiveStatus(item) === s)
28
+ items
29
+ .filter(({ item }) => effectiveStatus(item) === s)
30
+ .sort((a, b) => comparePriority(a.item, b.item))
23
31
  );
24
32
 
25
33
  if (items.length === 0) {