@ddtcorex/dsh-maestro-memory 1.1.1 → 1.2.2

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.
@@ -1,1078 +1,609 @@
1
1
  /**
2
- * dsh-maestro-memory — client entry (M3-PR-A: Todos subtab + dtodo RPC)
3
- * Single conversation.view slot (id: maestro-memory, order 40) with internal tabs.
2
+ * dsh-maestro-memory — client entry (redesign 2026-08-29)
3
+ * Uses maestro-design: Minimalism + Bento Grid, DSH tokens only, desktop+mobile.
4
4
  */
5
-
6
5
  import * as React from 'react'
7
6
 
8
7
  export const inject = ['slots', 'sessions', 'connection'] as const
9
8
 
10
9
  const RPC_CHANNEL = '/dsh-maestro-memory'
11
10
 
12
- // Theme-aware style values. DSH exposes these as CSS custom properties that
13
- // flip for light/dark (see Theme.listTokens). The previous hard-coded light
14
- // hex values made the view unreadable on the dark theme: an inherited white
15
- // label on white `#fff`/`#eee` buttons (white-on-white), plus bright `#ccc`/
16
- // `#ddd` borders and dark `#333`/`#666` text that disappeared against the
17
- // dark base. Using the --dsw-alias-* tokens keeps it readable in both themes.
18
- const STYLE = {
19
- text: 'var(--dsw-alias-label-primary)',
20
- textSec: 'var(--dsw-alias-label-secondary)',
21
- borderL1: '1px solid var(--dsw-alias-border-l1)',
22
- borderL2: '1px solid var(--dsw-alias-border-l2)',
23
- surface: 'var(--dsw-alias-bg-layer-1)',
24
- // Active tab/target highlight a filled overlay that reads as "selected" in
25
- // both themes (lighter over dark, grayish over light). Avoids bg-layer-2,
26
- // which collapses to white on the light theme.
27
- active: 'var(--dsw-alias-interactive-bg-active)',
28
- success: 'var(--dsw-alias-state-success-primary)',
29
- error: 'var(--dsw-alias-state-error-primary)',
30
- // Brand accent keeps white text (it's readable in both themes; the DSH
31
- // --dsw-alias-brand-primary token resolves to white here, which would make
32
- // white text invisible, so we don't use the token for a solid button fill).
33
- brand: '#06c',
34
- // Neutral secondary chip (Archive / Cancel / Undo): theme surface + primary label.
35
- neutral: 'var(--dsw-alias-bg-layer-2)',
36
- onAccent: '#fff', // white text on colored accents — readable in both themes
37
- }
11
+ // ── Design System Box ──────────────────────────────────────────────
12
+ // TARGET: dsh-maestro-memory Memory Tab
13
+ // PATTERN: Dashboard (Nav rail + Content bento)
14
+ // Sections: Header -> Nav (Memory | Queue | Todos | Skills | Health) -> Bento Cards
15
+ // STYLE: Minimalism + Bento Grid + Flat
16
+ // Keywords: clean, grid, whitespace, rounded 12, soft border, subtle hover 150ms
17
+ // Best For: tooling dashboards, memory/queue CRUD
18
+ // Performance: cost:low | Accessibility: risk:low
19
+ // COLORS: all via DSH --dsw-alias-* tokens (light/dark auto-flip)
20
+ // Primary: #06c (brand fill, white text) Secondary: var(--dsw-alias-interactive-bg-active)
21
+ // Background: var(--dsw-alias-bg-base/layer-1/layer-2) Text: label-primary/secondary
22
+ // Success: state-success-primary Error: state-error-primary Warn: state-warn-primary
23
+ // Notes: contrast 4.5:1 verified via host tokens; brand fill keeps white text in both themes
24
+ // TYPOGRAPHY: inherit host (DSH body 16/28, here 13/1.5 scoped) no per-element font stamp
25
+ // Mood: professional, readable Google Fonts: inherit
26
+ // KEY EFFECTS: hover 150ms, focus ring 2px, active translateY .5px, card hover border-l2
27
+ // AVOID: neon gradients, emoji-as-icon, no focus ring, hardcoded light hex
28
+ // PRE-DELIVERY: [x] 8 items — tokens, cursor-pointer, focus-visible, reduced-motion, contrast,
29
+ // reflow 375/200%, responsive 375/768/1024/1440, touch 44pt+8gap, alt/label
30
+ // ───────────────────────────────────────────────────────────────────
38
31
 
39
- // Scoped design tokens for the view so it reads like a first-class DSH panel.
40
- // Hover/focus/transition states are impossible with inline styles alone, so we
41
- // attach a small <style> scoped to the .dshmem container. All colors come from
42
- // the --dsw-alias-* theme tokens and flip with the DSH light/dark theme.
43
- // Typography is deliberately NOT declared here: the container inherits the DSH
44
- // text styles from the host tree (label-primary color, 16px/28px body text),
45
- // and every child resolves its font through normal CSS inheritance — no more
46
- // per-element `font: 13px system-ui` stamps that fight the host theme.
47
32
  const MEM_CSS = `
33
+ /* overlay isolation — Memory tab must fully cover chat behind (opaque + stacking) */
34
+ /* SlotOutlet [data-slot="conversation.view"] is display:contents by default (ui-slots/scoped-slots.tsx), so background/isolation on it is ignored. Override to block + paint. */
35
+ [class*="viewArea"] { background: var(--dsw-alias-bg-base) !important; isolation: isolate; contain: paint; position: relative; z-index: 1; flex: 1 1 auto; min-height: 100%; width: 100%; max-width: 100%; overflow: hidden; }
36
+ [data-slot="conversation.view"] { display: block !important; background: var(--dsw-alias-bg-base) !important; isolation: isolate; contain: paint; position: relative; z-index: 1; flex: 1 1 auto; min-height: 0; min-width: 0; width: 100%; max-width: 100%; pointer-events: auto; }
37
+ [data-slot="conversation.session"] { background: var(--dsw-alias-bg-base); isolation: isolate; contain: paint; }
38
+ [data-slot="conversation.view"] > .memx,
39
+ [class*="viewArea"] > .memx,
40
+ [class*="viewArea"] > [data-slot="conversation.view"] > .memx { flex: 1 1 auto; min-width: 0; max-width: 100%; }
41
+ /* legacy contract for tests — inheritance via .dshmem */
48
42
  .dshmem { color: var(--dsw-alias-label-primary); font-size: 13px; }
49
- /* Form controls carry a UA-stylesheet font (13.33px Arial) and do not
50
- inherit by default: opt them back into the host chain explicitly. */
51
- .dshmem button { font: inherit; cursor: pointer; transition: color .12s ease, background .12s ease, border-color .12s ease; }
52
- .dshmem button:hover { color: var(--dsw-alias-label-primary); }
53
- .dshmem button:active { transform: translateY(.5px); }
43
+ .dshmem button { font: inherit; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease, opacity .15s ease; }
54
44
  .dshmem button:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-active); outline-offset: 1px; }
55
- .dshmem input, .dshmem textarea, .dshmem select { font: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 5px 8px; }
56
- .dshmem input:focus, .dshmem textarea:focus, .dshmem select:focus { outline: none; border-color: var(--dsw-alias-border-l2); }
57
- .dshmem textarea { resize: vertical; }
58
- .dshmem .card { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px; }
59
- .dshmem .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
60
- .dshmem .muted { color: var(--dsw-alias-label-secondary); }
61
-
62
- /* Top-level view navigation an underline tab bar (mirrors the DSH
63
- * Chat/Trajectory/Memory header tabs above it), NOT boxed chips, so it reads
64
- * as navigation and stays distinct from the memory-track filters below. */
65
- .dshmem .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l1); margin-bottom: 14px; }
66
- .dshmem .tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 7px 12px; border-radius: 0; font-weight: 500; color: var(--dsw-alias-label-secondary); }
67
- .dshmem .tab:hover { color: var(--dsw-alias-label-primary); }
68
- .dshmem .tab-active { color: var(--dsw-alias-label-primary); font-weight: 700; border-bottom-color: #06c; }
69
-
70
- /* Memory-track / date filters + actions — one consistent control height so
71
- * nothing reflows or misaligns when a field appears/disappears. */
72
- .dshmem .tracklabel { text-transform: uppercase; letter-spacing: .04em; color: var(--dsw-alias-label-secondary); }
73
- .dshmem .control { height: 28px; display: inline-flex; align-items: center; gap: 6px; line-height: 1; }
74
- .dshmem .pill { border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); padding: 0 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); }
75
- .dshmem .pill:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
76
- .dshmem .pill-active { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); font-weight: 600; border-color: var(--dsw-alias-border-l2); }
77
- .dshmem .ghost { border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); padding: 0 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); }
78
- .dshmem .ghost:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
79
- .dshmem .cwd { box-sizing: border-box; height: 28px; flex: 1; min-width: 180px; padding: 0 10px; }
80
- .dshmem .cwd:disabled { opacity: .5; cursor: not-allowed; }
81
- .dshmem .cwd:disabled::placeholder { color: var(--dsw-alias-label-secondary); }
82
- .dshmem .tabbadge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: var(--dsw-alias-state-error-primary, #d9534f); color: #fff; font-size: 11px; font-weight: 700; line-height: 1; vertical-align: middle; }
45
+ .dshmem input, .dshmem textarea, .dshmem select { font: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; }
46
+ .memx { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; background: var(--dsw-alias-bg-base) !important; position: relative; z-index: 2; isolation: isolate; contain: paint; min-height: 100%; width: 100%; max-width: 100%; box-sizing: border-box; align-self: stretch; flex: 1 1 auto; pointer-events: auto; overflow-x: clip; overflow-y: auto; overflow-wrap: break-word; }
47
+ .memx, .memx * { box-sizing: border-box; }
48
+ .memx button { font: inherit; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease, opacity .15s ease; }
49
+ .memx button:active { transform: translateY(.5px); }
50
+ .memx button:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-active); outline-offset: 1px; }
51
+ .memx input, .memx textarea, .memx select { font: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; }
52
+ .memx input:focus, .memx textarea:focus, .memx select:focus { outline: none; border-color: var(--dsw-alias-border-l2); box-shadow: 0 0 0 2px var(--dsw-alias-interactive-bg-active); }
53
+ .memx textarea { resize: vertical; }
54
+ @media (prefers-reduced-motion: reduce) { .memx button, .memx-card, .dshmem-header-badge { transition: none !important; animation: none !important; } }
55
+
56
+ /* header refresh ngang hàng title (yêu cầu) */
57
+ .memx-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin-bottom:14px; }
58
+ .memx-title { font-size:16px; font-weight:700; letter-spacing:-.01em; display:flex; align-items:center; gap:8px; }
59
+ .memx-title svg { flex:none; }
60
+ .memx-subtitle { color: var(--dsw-alias-label-secondary); font-size:12px; margin-top:2px; }
61
+ .memx-header-main { flex:1 1 auto; min-width:0; }
62
+ .memx-header-actions { flex:0 0 auto; display:flex; gap:8px; align-items:center; margin-left:auto; }
63
+ .memx-search { display:flex; align-items:center; gap:6px; background:var(--dsw-alias-bg-layer-2); border:1px solid var(--dsw-alias-border-l1); border-radius:999px; padding:6px 10px; min-width:0; flex:1 1 160px; max-width:320px; }
64
+ @media (max-width:1023px) { .memx-search { max-width:100%; flex-basis:100%; } }
65
+ .memx-search input { border:none; background:transparent; padding:0; flex:1; min-width:0; box-shadow:none !important; }
66
+ .memx-search:focus-within { border-color:var(--dsw-alias-border-l2); box-shadow:0 0 0 2px var(--dsw-alias-interactive-bg-active); }
67
+ .memx-iconbtn { width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; border-radius:10px; border:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-secondary); }
68
+ .memx-iconbtn:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-2); }
69
+ .memx-iconbtn[aria-busy="true"] { opacity:.6; pointer-events:none; }
70
+
71
+ /* layout align breakpoint with dsh-maestro-mobile (1023px) */
72
+ .memx-layout { display:grid; gap:14px; width:100%; max-width:100%; min-width:0; }
73
+ @media (min-width: 1024px) { .memx-layout { grid-template-columns: 200px minmax(0,1fr); align-items:start; } }
74
+ /* nav */
75
+ .memx-nav { display:flex; gap:8px; overflow-x:auto; scrollbar-width:none; -webkit-overflow-scrolling:touch; padding-bottom:2px; }
76
+ .memx-nav::-webkit-scrollbar { display:none; }
77
+ @media (min-width: 1024px) { .memx-nav { flex-direction:column; position:sticky; top:8px; overflow:visible; gap:4px; } }
78
+ .memx-navitem { display:inline-flex; align-items:center; gap:8px; padding:10px 12px; border-radius:10px; border:1px solid transparent; background:transparent; color:var(--dsw-alias-label-secondary); white-space:nowrap; min-height:44px; }
79
+ .memx-navitem:hover { background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l1); }
80
+ .memx-navitem[aria-selected="true"], .memx-navitem[aria-current="page"] { background:var(--dsw-alias-interactive-bg-active); color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); font-weight:600; }
81
+ .memx-navitem svg { width:16px; height:16px; flex:none; }
82
+ .memx-badge { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; border-radius:999px; background:var(--dsw-alias-state-error-primary); color:#fff; font-size:11px; font-weight:700; line-height:1; }
83
+ .memx-navcount { margin-left:auto; color:var(--dsw-alias-label-secondary); font-size:11px; }
84
+
85
+ /* content */
86
+ .memx-panel { min-width:0; max-width:100%; width:100%; display:flex; flex-direction:column; gap:12px; overflow:hidden; }
87
+ .memx-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
88
+ .memx-pills { display:flex; gap:8px; flex-wrap:wrap; }
89
+ .memx-pill { border-radius:999px; border:1px solid var(--dsw-alias-border-l1); padding:6px 12px; background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-secondary); min-height:32px; display:inline-flex; align-items:center; }
90
+ .memx-pill:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); }
91
+ .memx-pill[aria-pressed="true"] { background:var(--dsw-alias-interactive-bg-active); color:var(--dsw-alias-label-primary); font-weight:600; border-color:var(--dsw-alias-border-l2); }
92
+ .memx-field { display:flex; gap:8px; align-items:center; flex-wrap:wrap; flex:1 1 100%; min-width:0; }
93
+ .memx-input { height:44px; padding:0 10px; flex:1 1 140px; min-width:0; }
94
+ @media (max-width:480px) { .memx-field { flex-direction:column; align-items:stretch; } .memx-input { flex-basis:auto; width:100%; } }
95
+ .memx-input:disabled { opacity:.5; cursor:not-allowed; }
96
+ .memx-btn { min-height:44px; padding:0 14px; border-radius:8px; border:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); display:inline-flex; align-items:center; justify-content:center; gap:6px; font-weight:500; }
97
+ .memx-btn:hover { border-color:var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-2); }
98
+ .memx-btn-primary { background:#06c; color:#fff; border-color:#06c; }
99
+ .memx-btn-primary:hover { background:#05a; border-color:#05a; color:#fff; }
100
+ .memx-btn-ghost { background:transparent; }
101
+ .memx-btn-danger { background:var(--dsw-alias-state-error-primary); color:#fff; border-color:transparent; }
102
+ .memx-btn:disabled { opacity:.5; cursor:not-allowed; }
103
+
104
+ /* bento / cards */
105
+ .memx-grid { display:grid; gap:10px; width:100%; max-width:100%; min-width:0; }
106
+ @media (min-width: 640px) { .memx-grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
107
+ @media (min-width: 1024px) { .memx-grid-4 { grid-template-columns: repeat(4, minmax(0,1fr)); } }
108
+ .memx-card { border:1px solid var(--dsw-alias-border-l1); border-radius:12px; background:var(--dsw-alias-bg-layer-1); padding:12px; display:flex; flex-direction:column; gap:8px; min-width:0; max-width:100%; overflow:hidden; }
109
+ .memx-card:hover { border-color:var(--dsw-alias-border-l2); }
110
+ .memx-cardhead { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
111
+ .memx-kicker { text-transform:uppercase; letter-spacing:.04em; font-size:11px; color:var(--dsw-alias-label-secondary); font-weight:600; }
112
+ .memx-cardtitle { font-weight:600; }
113
+ .memx-muted { color:var(--dsw-alias-label-secondary); }
114
+ .memx-sm { font-size:12px; color:var(--dsw-alias-label-secondary); }
115
+ .memx-xs { font-size:11px; color:var(--dsw-alias-label-secondary); }
116
+ .memx-pre { white-space:pre-wrap; word-break:break-word; }
117
+ .memx-pre-sm { white-space:pre-wrap; word-break:break-word; font-size:12px; }
118
+ .memx-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 8px; border-radius:999px; border:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-2); font-size:11px; color:var(--dsw-alias-label-secondary); }
119
+ .memx-empty { border:1px dashed var(--dsw-alias-border-l2); border-radius:12px; padding:24px; text-align:center; color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); }
120
+ .memx-stat { text-align:left; }
121
+ .memx-statval { font-size:20px; font-weight:800; letter-spacing:-.02em; }
122
+ .memx-actions { display:flex; gap:8px; flex-wrap:wrap; }
123
+ @media (max-width: 639px) { .memx-actions button { flex:1; min-width:0; } }
124
+
125
+ /* forms inline */
126
+ .memx-form { border:1px solid var(--dsw-alias-border-l1); border-radius:12px; background:var(--dsw-alias-bg-layer-1); padding:12px; display:flex; flex-direction:column; gap:10px; min-width:0; max-width:100%; }
127
+ .memx-formrow { display:flex; gap:8px; flex-wrap:wrap; min-width:0; }
128
+ .memx-formrow > * { flex:1 1 0; min-width:120px; max-width:100%; }
129
+ @media (max-width:1023px) { .memx { padding:12px 12px calc(16px + env(safe-area-inset-bottom,0px)) !important; } .memx-layout { gap:12px; grid-template-columns:1fr !important; } .memx-formrow > *{ min-width:0; } }
130
+ @media (max-width:480px) { .memx-field { flex-direction:column; align-items:stretch; } .memx-input { flex-basis:auto; width:100%; max-width:100%; } }
131
+ @media (min-width:1440px) { .memx { max-width:1280px; margin:0 auto; } }
83
132
  `
84
133
 
85
- // Global CSS for the top-level header tab badge (injected into <head> by the
86
- // boot-time effect). Reuses DSH theme tokens so it flips with light/dark; a
87
- // soft blink draws attention to pending reviews without being obnoxious.
88
134
  const HEADER_BADGE_CSS = `
89
- .dshmem-header-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: var(--dsw-alias-state-error-primary, #d9534f); color: #fff; font-size: 11px; font-weight: 700; line-height: 1; vertical-align: middle; animation: dshmem-blink 1.2s ease-in-out infinite; }
90
- @keyframes dshmem-blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
135
+ .dshmem-header-badge { display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; padding:0 5px; margin-left:6px; border-radius:999px; background:var(--dsw-alias-state-error-primary,#d9534f); color:#fff; font-size:11px; font-weight:700; line-height:1; vertical-align:middle; animation:dshmem-blink 1.2s ease-in-out infinite; }
136
+ @keyframes dshmem-blink { 0%,100%{opacity:1} 50%{opacity:.3} }
91
137
  `
92
138
 
93
- // Best-effort current-session cwd, derived from the injected `sessions` feed
94
- // (never `ctx.conversation` — that face is not injectable here, and touching a
95
- // non-injected service throws `cannot get property ... without inject`).
96
- // `ctx.sessions.list.getSnapshot()` returns `{ current, byId }`, and each row
97
- // carries the session's canonical `cwd`. Undefined on a fresh page / no session.
98
139
  function sessionCwd(ctx: any): string {
99
140
  try {
100
141
  const snap = ctx?.sessions?.list?.getSnapshot?.()
101
142
  const id: string | undefined = snap?.current
102
143
  if (!id) return ''
103
144
  return (snap?.byId?.[id]?.cwd as string) ?? ''
104
- } catch {
105
- return ''
106
- }
145
+ } catch { return '' }
107
146
  }
108
147
 
109
148
  function useRpc(ctx: any) {
110
- return React.useCallback(
111
- (endpoint: string, payload: any) => {
112
- const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
113
- if (!conn?.rpc?.call) return Promise.reject(new Error('RPC not available'))
114
- return conn.rpc.call(RPC_CHANNEL, endpoint, payload).then((result: any) => {
115
- if (result?.ok === true) return result.value
116
- const message = typeof result?.error?.message === 'string' ? result.error.message : 'RPC request failed'
117
- throw new Error(message)
118
- })
119
- },
120
- [ctx],
121
- )
149
+ return React.useCallback((endpoint: string, payload: any) => {
150
+ const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
151
+ if (!conn?.rpc?.call) return Promise.reject(new Error('RPC not available'))
152
+ return conn.rpc.call(RPC_CHANNEL, endpoint, payload).then((result: any) => {
153
+ if (result?.ok === true) return result.value
154
+ const message = typeof result?.error?.message === 'string' ? result.error.message : 'RPC request failed'
155
+ throw new Error(message)
156
+ })
157
+ }, [ctx])
122
158
  }
123
159
 
124
- function ReviewQueueView({ ctx, onPendingChange }: { ctx: any; onPendingChange?: (n: number) => void }): React.ReactElement {
125
- const rpc = useRpc(ctx)
126
- const [entries, setEntries] = React.useState<any[]>([])
127
- const [loading, setLoading] = React.useState(true)
128
- const [edits, setEdits] = React.useState<Record<number, string>>({})
129
- const [msg, setMsg] = React.useState<string>('')
130
-
131
- const load = React.useCallback(async () => {
132
- setLoading(true)
133
- try {
134
- const res: any = await rpc('queue.list', {})
135
- const list = Array.isArray(res?.entries) ? res.entries : []
136
- setEntries(list)
137
- onPendingChange?.(list.length)
138
- } catch (e: any) {
139
- setMsg(`load failed: ${e?.message ?? String(e)}`)
140
- } finally {
141
- setLoading(false)
142
- }
143
- }, [rpc, onPendingChange])
144
-
145
- React.useEffect(() => {
146
- load()
147
- }, [load])
148
-
149
- const decide = React.useCallback(
150
- async (action: 'approve' | 'reject' | 'archive', index: number) => {
151
- setMsg('')
152
- const payload: any = { action, indices: [index] }
153
- if (action === 'approve' && edits[index] !== undefined && edits[index].trim() !== '') {
154
- payload.edits = { [String(index)]: edits[index] }
155
- }
156
- try {
157
- const res: any = await rpc('queue.decide', payload)
158
- if (res?.ok) {
159
- setMsg(res.lines ? res.lines.join('; ') : `${action} ok`)
160
- await load()
161
- } else {
162
- setMsg(`failed: ${res?.error ?? 'unknown'}`)
163
- }
164
- } catch (e: any) {
165
- setMsg(`error: ${e?.message ?? String(e)}`)
166
- }
167
- },
168
- [rpc, edits, load],
169
- )
170
-
171
- if (loading) return React.createElement('div', null, 'Loading queue…')
172
-
173
- if (entries.length === 0) {
174
- return React.createElement(
175
- 'div',
176
- null,
177
- React.createElement('div', { style: { opacity: 0.7, marginBottom: 8 } }, 'No pending suggestions'),
178
- msg ? React.createElement('div', { style: { color: STYLE.textSec } }, msg) : null,
179
- React.createElement('button', { onClick: load, style: { marginTop: 8 } }, 'Refresh'),
180
- )
181
- }
182
-
183
- return React.createElement(
184
- 'div',
185
- null,
186
- React.createElement('div', { style: { marginBottom: 8, opacity: 0.7 } }, `${entries.length} pending`),
187
- ...entries.map((e: any, idx: number) => {
188
- const number = idx + 1
189
- return React.createElement(
190
- 'div',
191
- {
192
- key: number,
193
- style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 },
194
- },
195
- React.createElement('div', { style: { fontWeight: 600 } }, `#${number} [${e.target}]`),
196
- React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, e.content),
197
- e.reason ? React.createElement('div', { style: { opacity: 0.7 } }, `Reason: ${e.reason}`) : null,
198
- React.createElement('textarea', {
199
- value: edits[number] ?? '',
200
- placeholder: 'Edit content before approve (optional)',
201
- onChange: (ev: any) => setEdits((prev) => ({ ...prev, [number]: ev.target.value })),
202
- style: { width: '100%', minHeight: 40, marginTop: 4 },
203
- }),
204
- React.createElement(
205
- 'div',
206
- { style: { display: 'flex', gap: 8, marginTop: 8 } },
207
- React.createElement(
208
- 'button',
209
- {
210
- onClick: () => decide('approve', number),
211
- 'data-testid': `approve-${number}`,
212
- style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
213
- },
214
- 'Approve',
215
- ),
216
- React.createElement(
217
- 'button',
218
- {
219
- onClick: () => decide('reject', number),
220
- 'data-testid': `reject-${number}`,
221
- style: { background: STYLE.error, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
222
- },
223
- 'Reject',
224
- ),
225
- React.createElement(
226
- 'button',
227
- {
228
- onClick: () => decide('archive', number),
229
- 'data-testid': `archive-${number}`,
230
- style: { background: STYLE.neutral, color: STYLE.text, border: STYLE.borderL1, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
231
- },
232
- 'Archive',
233
- ),
234
- ),
235
- )
236
- }),
237
- msg ? React.createElement('div', { style: { marginTop: 8, color: STYLE.text } }, msg) : null,
238
- React.createElement('button', { onClick: load, style: { marginTop: 8 } }, 'Refresh'),
239
- )
160
+ // tiny inline icons (no emoji) stroke currentColor
161
+ function Ico({ d, size = 16, label }: { d: string; size?: number; label?: string }) {
162
+ return React.createElement('span', { 'aria-hidden': label ? undefined : true, 'aria-label': label, style: { display:'inline-flex', lineHeight:0 } },
163
+ React.createElement('svg', { width: size, height: size, viewBox:'0 0 24 24', fill:'none', stroke:'currentColor', strokeWidth:1.75, strokeLinecap:'round', strokeLinejoin:'round' as any, 'aria-hidden': true, focusable: 'false' } as any,
164
+ React.createElement('path', { d })))
165
+ }
166
+ const ICO = {
167
+ mem: 'M4 6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6z M8 8h4 M8 12h4 M8 16h4 M16 8h2 M16 12h2 M16 16h2',
168
+ inbox: 'M4 6h16v12H4z M4 6l8 7 8-7',
169
+ check: 'M9 12l2 2 4-4 M4 6h16v12H4z',
170
+ layers: 'M12 3l9 5-9 5-9-5 9-5z M3 12l9 5 9-5 M3 17l9 5 9-5',
171
+ activity: 'M3 12h4l3-8 4 16 3-8h4',
172
+ search: 'M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14z M18 18l-3.5-3.5',
173
+ refresh: 'M21 12a9 9 0 1 1-2.6-6.4 M21 3v6h-6',
174
+ plus: 'M12 5v14 M5 12h14',
175
+ spark: 'M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5z',
240
176
  }
241
177
 
242
- function TodosView({ ctx }: { ctx: any }): React.ReactElement {
178
+ // ── Review ─────────────────────────────────────────────────────────
179
+ function ReviewQueueView({ ctx, onPendingChange }: { ctx:any; onPendingChange?:(n:number)=>void }) : React.ReactElement {
243
180
  const rpc = useRpc(ctx)
244
- const [items, setItems] = React.useState<any[]>([])
245
- const [loading, setLoading] = React.useState(true)
246
- const [msg, setMsg] = React.useState<string>('')
247
- const [target, setTarget] = React.useState<string>('all')
248
- const [showAll, setShowAll] = React.useState(false)
249
- const [showPast, setShowPast] = React.useState(false)
250
- const [showExpired, setShowExpired] = React.useState(false)
251
- const [draftContent, setDraftContent] = React.useState('')
252
- const [draftTarget, setDraftTarget] = React.useState<string>('work')
253
- const [draftDue, setDraftDue] = React.useState('')
254
- const [draftQuadrant, setDraftQuadrant] = React.useState<string>('')
255
- const [draftCat, setDraftCat] = React.useState('')
256
- const [editId, setEditId] = React.useState<string | null>(null)
257
- const [editContent, setEditContent] = React.useState('')
258
- const [editDue, setEditDue] = React.useState('')
259
- const [editQuadrant, setEditQuadrant] = React.useState('')
260
- const [editStatus, setEditStatus] = React.useState('')
261
- const [editCat, setEditCat] = React.useState('')
181
+ const [entries,setEntries]=React.useState<any[]>([])
182
+ const [loading,setLoading]=React.useState(true)
183
+ const [edits,setEdits]=React.useState<Record<number,string>>({})
184
+ const [msg,setMsg]=React.useState('')
185
+ const [q,setQ]=React.useState('')
262
186
 
263
- const load = React.useCallback(async () => {
187
+ const load = React.useCallback(async()=>{
264
188
  setLoading(true)
265
- setMsg('')
266
- try {
267
- const rpcPayload: any = {
268
- target: target !== 'all' ? target : undefined,
269
- opts: {
270
- all: showAll,
271
- past: showPast,
272
- expired: showExpired,
273
- },
274
- }
275
- // For single target we need to pass correctly; todo.list expects target or targets
276
- const res: any = await rpc('todo.list', rpcPayload)
277
- const list = Array.isArray(res?.items) ? res.items : []
278
- setItems(list)
279
- if (res?.hint) setMsg(res.hint)
280
- else if (res?.text) {
281
- // fallback: text contains items but we already have items
282
- }
283
- } catch (e: any) {
284
- setMsg(`load failed: ${e?.message ?? String(e)}`)
285
- } finally {
286
- setLoading(false)
287
- }
288
- }, [rpc, target, showAll, showPast, showExpired])
289
-
290
- React.useEffect(() => {
291
- load()
292
- }, [load])
189
+ try{ const res:any=await rpc('queue.list',{}); const list=Array.isArray(res?.entries)?res.entries:[]; setEntries(list); onPendingChange?.(list.length) }
190
+ catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
191
+ },[rpc,onPendingChange])
192
+ React.useEffect(()=>{ load() },[load])
293
193
 
294
- const addTodo = React.useCallback(async () => {
295
- const content = draftContent.trim()
296
- if (!content) {
297
- setMsg('content required')
298
- return
299
- }
194
+ const decide = React.useCallback(async(action:'approve'|'reject'|'archive', index:number)=>{
300
195
  setMsg('')
301
- try {
302
- const res: any = await rpc('todo.mutate', {
303
- action: 'add',
304
- target: draftTarget,
305
- content,
306
- due: draftDue || undefined,
307
- quadrant: draftQuadrant || undefined,
308
- cat: draftCat || undefined,
309
- })
310
- if (res?.ok) {
311
- setDraftContent('')
312
- setDraftDue('')
313
- setDraftQuadrant('')
314
- setDraftCat('')
315
- setMsg(`added (id: ${res.id})`)
316
- await load()
317
- } else {
318
- setMsg(`add failed: ${res?.message ?? res?.error ?? 'unknown'}`)
319
- }
320
- } catch (e: any) {
321
- setMsg(`error: ${e?.message ?? String(e)}`)
322
- }
323
- }, [rpc, draftContent, draftTarget, draftDue, draftQuadrant, draftCat, load])
324
-
325
- const doneTodo = React.useCallback(
326
- async (item: any) => {
327
- try {
328
- const res: any = await rpc('todo.mutate', { action: 'done', target: item.target, id: item.id })
329
- if (res?.ok) {
330
- setMsg(res.message ?? 'done')
331
- await load()
332
- } else setMsg(`done failed: ${res?.message ?? res?.error}`)
333
- } catch (e: any) {
334
- setMsg(`error: ${e?.message ?? String(e)}`)
335
- }
336
- },
337
- [rpc, load],
338
- )
339
-
340
- const undoTodo = React.useCallback(
341
- async (item: any) => {
342
- try {
343
- // Re-open a done todo: set status back to pending (only if currently done).
344
- const res: any = await rpc('todo.mutate', { action: 'update', target: item.target, id: item.id, status: 'pending' })
345
- if (res?.ok) {
346
- setMsg(res.message ?? 'undone')
347
- await load()
348
- } else setMsg(`undo failed: ${res?.message ?? res?.error}`)
349
- } catch (e: any) {
350
- setMsg(`error: ${e?.message ?? String(e)}`)
351
- }
352
- },
353
- [rpc, load],
354
- )
355
-
356
- const removeTodo = React.useCallback(
357
- async (item: any) => {
358
- try {
359
- const res: any = await rpc('todo.mutate', { action: 'remove', target: item.target, id: item.id })
360
- if (res?.ok) {
361
- setMsg(res.message ?? 'removed')
362
- await load()
363
- } else setMsg(`remove failed: ${res?.message ?? res?.error}`)
364
- } catch (e: any) {
365
- setMsg(`error: ${e?.message ?? String(e)}`)
366
- }
367
- },
368
- [rpc, load],
369
- )
370
-
371
- const startEdit = (item: any) => {
372
- setEditId(item.id)
373
- setEditContent(item.text ?? '')
374
- setEditDue(item.due ?? '')
375
- setEditQuadrant(item.quadrant ?? '')
376
- setEditStatus(item.status ?? 'pending')
377
- setEditCat(item.cat ?? '')
378
- }
379
-
380
- const saveEdit = React.useCallback(
381
- async (item: any) => {
382
- try {
383
- const patch: any = {}
384
- if (editContent !== item.text) patch.content = editContent
385
- if (editQuadrant !== (item.quadrant ?? '')) patch.quadrant = editQuadrant || null
386
- if (editDue !== (item.due ?? '')) patch.due = editDue || null
387
- if (editStatus !== item.status) patch.status = editStatus
388
- if (editCat !== (item.cat ?? '')) patch.cat = editCat || null
389
- const res: any = await rpc('todo.mutate', {
390
- action: 'update',
391
- target: item.target,
392
- id: item.id,
393
- ...patch,
394
- })
395
- if (res?.ok) {
396
- setEditId(null)
397
- setMsg(res.message ?? 'updated')
398
- await load()
399
- } else setMsg(`update failed: ${res?.message ?? res?.error}`)
400
- } catch (e: any) {
401
- setMsg(`error: ${e?.message ?? String(e)}`)
402
- }
403
- },
404
- [rpc, editContent, editDue, editQuadrant, editStatus, editCat, load],
405
- )
406
-
407
- return React.createElement(
408
- 'div',
409
- null,
410
- React.createElement(
411
- 'div',
412
- { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 } },
413
- (['all', 'life', 'work', 'project', 'daily'] as const).map((k) =>
414
- React.createElement(
415
- 'button',
416
- {
417
- key: k,
418
- onClick: () => setTarget(k),
419
- 'data-testid': `todo-target-${k}`,
420
- style: {
421
- fontWeight: target === k ? 700 : 400,
422
- padding: '4px 8px',
423
- borderRadius: 6,
424
- border: target === k ? STYLE.borderL2 : STYLE.borderL1,
425
- background: target === k ? STYLE.active : STYLE.surface,
426
- color: STYLE.text,
427
- cursor: 'pointer',
428
- },
429
- },
430
- k,
431
- ),
432
- ),
433
- ),
434
- React.createElement(
435
- 'div',
436
- { style: { display: 'flex', gap: 12, marginBottom: 8, alignItems: 'center', flexWrap: 'wrap' } },
437
- React.createElement(
438
- 'label',
439
- { style: { display: 'flex', gap: 4, alignItems: 'center' } },
440
- React.createElement('input', {
441
- type: 'checkbox',
442
- checked: showAll,
443
- onChange: (e: any) => setShowAll(e.target.checked),
444
- 'data-testid': 'todo-all',
445
- }),
446
- 'all (no limit)',
447
- ),
448
- React.createElement(
449
- 'label',
450
- { style: { display: 'flex', gap: 4, alignItems: 'center' } },
451
- React.createElement('input', {
452
- type: 'checkbox',
453
- checked: showPast,
454
- onChange: (e: any) => setShowPast(e.target.checked),
455
- 'data-testid': 'todo-past',
456
- }),
457
- 'past',
458
- ),
459
- React.createElement(
460
- 'label',
461
- { style: { display: 'flex', gap: 4, alignItems: 'center' } },
462
- React.createElement('input', {
463
- type: 'checkbox',
464
- checked: showExpired,
465
- onChange: (e: any) => setShowExpired(e.target.checked),
466
- 'data-testid': 'todo-expired',
467
- }),
468
- 'expired',
469
- ),
470
- React.createElement(
471
- 'button',
472
- { onClick: load, style: { padding: '4px 8px' }, 'data-testid': 'todo-refresh' },
473
- 'Refresh',
474
- ),
475
- ),
476
- React.createElement(
477
- 'div',
478
- { style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 12 } },
479
- React.createElement('div', { style: { fontWeight: 600, marginBottom: 6 } }, 'Add todo'),
480
- React.createElement('textarea', {
481
- value: draftContent,
482
- placeholder: 'Todo content',
483
- onChange: (e: any) => setDraftContent(e.target.value),
484
- style: { width: '100%', minHeight: 40, marginBottom: 6 },
485
- 'data-testid': 'todo-add-content',
486
- }),
487
- React.createElement(
488
- 'div',
489
- { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 } },
490
- React.createElement(
491
- 'select',
492
- {
493
- value: draftTarget,
494
- onChange: (e: any) => setDraftTarget(e.target.value),
495
- style: { padding: '4px' },
496
- 'data-testid': 'todo-add-target',
497
- },
498
- (['life', 'work', 'project', 'daily'] as const).map((t) => React.createElement('option', { key: t, value: t }, t)),
499
- ),
500
- React.createElement(
501
- 'select',
502
- {
503
- value: draftQuadrant,
504
- onChange: (e: any) => setDraftQuadrant(e.target.value),
505
- style: { padding: '4px' },
506
- 'data-testid': 'todo-add-quadrant',
507
- },
508
- React.createElement('option', { value: '' }, 'quadrant'),
509
- React.createElement('option', { value: 'q1' }, 'q1'),
510
- React.createElement('option', { value: 'q2' }, 'q2'),
511
- React.createElement('option', { value: 'q3' }, 'q3'),
512
- React.createElement('option', { value: 'q4' }, 'q4'),
513
- ),
514
- React.createElement('input', {
515
- value: draftDue,
516
- placeholder: 'due YYYY-MM-DD',
517
- onChange: (e: any) => setDraftDue(e.target.value),
518
- style: { padding: '4px', width: 120 },
519
- 'data-testid': 'todo-add-due',
520
- }),
521
- React.createElement('input', {
522
- value: draftCat,
523
- placeholder: 'cat',
524
- onChange: (e: any) => setDraftCat(e.target.value),
525
- style: { padding: '4px', width: 80 },
526
- 'data-testid': 'todo-add-cat',
527
- }),
528
- ),
529
- React.createElement(
530
- 'button',
531
- {
532
- onClick: addTodo,
533
- style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '6px 12px', cursor: 'pointer' },
534
- 'data-testid': 'todo-add-btn',
535
- },
536
- 'Add',
196
+ const payload:any={ action, indices:[index] }
197
+ if(action==='approve' && edits[index]!==undefined && edits[index].trim()!=='') payload.edits={ [String(index)]: edits[index] }
198
+ try{ const res:any=await rpc('queue.decide',payload); if(res?.ok){ setMsg(res.lines?res.lines.join('; '):`${action} ok`); await load() } else setMsg(`failed: ${res?.error??'unknown'}`) }
199
+ catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
200
+ },[rpc,edits,load])
201
+
202
+ const filtered = React.useMemo(()=>{
203
+ const t=q.trim().toLowerCase(); if(!t) return entries
204
+ return entries.filter((e:any)=> `${e.target} ${e.content} ${e.reason}`.toLowerCase().includes(t))
205
+ },[entries,q])
206
+
207
+ if(loading) return React.createElement('div', {className:'memx-muted'}, 'Loading queue…')
208
+ return React.createElement('div', {className:'memx-panel'},
209
+ React.createElement('div', {className:'memx-toolbar'},
210
+ React.createElement('div', {className:'memx-search', role:'search', 'aria-label':'Filter queue'},
211
+ React.createElement(Ico,{d:ICO.search}), React.createElement('input',{value:q, onChange:(e:any)=>setQ(e.target.value), placeholder:'Filter queue…', 'aria-label':'Filter queue'}),
537
212
  ),
213
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'aria-label':'Refresh queue'}, React.createElement(Ico,{d:ICO.refresh}), ' Refresh'),
214
+ React.createElement('span',{className:'memx-muted'}, `${filtered.length}/${entries.length} pending`),
538
215
  ),
539
- msg ? React.createElement('div', { style: { color: STYLE.text, marginBottom: 8, whiteSpace: 'pre-wrap' } }, msg) : null,
540
- loading
541
- ? React.createElement('div', null, 'Loading todos…')
542
- : items.length === 0
543
- ? React.createElement('div', { style: { opacity: 0.7 } }, 'No todos (smart view: overdue / due today / current project / Q1-Q2, max 8). Try "all" or "past".')
544
- : React.createElement(
545
- 'div',
546
- null,
547
- React.createElement('div', { style: { opacity: 0.7, marginBottom: 6 } }, `${items.length} todos${!showAll && items.length === 8 ? ' (smart view limited to 8)' : ''}`),
548
- ...items.map((it: any) =>
549
- React.createElement(
550
- 'div',
551
- { key: it.id, style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 } },
552
- React.createElement(
553
- 'div',
554
- { style: { opacity: 0.7 } },
555
- `[${it.target}]`,
556
- it.quadrant ? ` [${it.quadrant}]` : '',
557
- it.due ? ` [due:${it.due}]` : '',
558
- it.status !== 'pending' ? ` [${it.status}]` : '',
559
- it.cat ? ` [cat:${it.cat}]` : '',
560
- it.doneAt ? ` [done:${it.doneAt}]` : '',
561
- it.past ? ` [past ${it.day}]` : '',
562
- ` id:${it.id}`,
563
- ),
564
- editId === it.id
565
- ? React.createElement(
566
- 'div',
567
- null,
568
- React.createElement('textarea', {
569
- value: editContent,
570
- onChange: (e: any) => setEditContent(e.target.value),
571
- style: { width: '100%', minHeight: 40, marginTop: 4 },
572
- 'data-testid': `todo-edit-content-${it.id}`,
573
- }),
574
- React.createElement(
575
- 'div',
576
- { style: { display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
577
- React.createElement(
578
- 'select',
579
- {
580
- value: editQuadrant,
581
- onChange: (e: any) => setEditQuadrant(e.target.value),
582
- 'data-testid': `todo-edit-quadrant-${it.id}`,
583
- },
584
- React.createElement('option', { value: '' }, 'no quadrant'),
585
- React.createElement('option', { value: 'q1' }, 'q1'),
586
- React.createElement('option', { value: 'q2' }, 'q2'),
587
- React.createElement('option', { value: 'q3' }, 'q3'),
588
- React.createElement('option', { value: 'q4' }, 'q4'),
589
- ),
590
- React.createElement('input', {
591
- value: editDue,
592
- placeholder: 'due YYYY-MM-DD',
593
- onChange: (e: any) => setEditDue(e.target.value),
594
- style: { width: 120 },
595
- 'data-testid': `todo-edit-due-${it.id}`,
596
- }),
597
- React.createElement(
598
- 'select',
599
- {
600
- value: editStatus,
601
- onChange: (e: any) => setEditStatus(e.target.value),
602
- 'data-testid': `todo-edit-status-${it.id}`,
603
- },
604
- (['pending', 'doing', 'done', 'blocked', 'cancelled'] as const).map((s) => React.createElement('option', { key: s, value: s }, s)),
605
- ),
606
- React.createElement('input', {
607
- value: editCat,
608
- placeholder: 'cat',
609
- onChange: (e: any) => setEditCat(e.target.value),
610
- style: { width: 80 },
611
- 'data-testid': `todo-edit-cat-${it.id}`,
612
- }),
613
- ),
614
- React.createElement(
615
- 'div',
616
- { style: { display: 'flex', gap: 8, marginTop: 8 } },
617
- React.createElement(
618
- 'button',
619
- {
620
- onClick: () => saveEdit(it),
621
- style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
622
- 'data-testid': `todo-save-${it.id}`,
623
- },
624
- 'Save',
625
- ),
626
- React.createElement(
627
- 'button',
628
- {
629
- onClick: () => setEditId(null),
630
- style: { background: STYLE.neutral, color: STYLE.text, border: STYLE.borderL1, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
631
- 'data-testid': `todo-cancel-${it.id}`,
632
- },
633
- 'Cancel',
634
- ),
635
- ),
636
- )
637
- : React.createElement(
638
- 'div',
639
- null,
640
- React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, it.text),
641
- React.createElement(
642
- 'div',
643
- { style: { display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' } },
644
- React.createElement(
645
- 'button',
646
- {
647
- onClick: () => (it.status === 'done' ? undoTodo(it) : doneTodo(it)),
648
- style: { background: it.status === 'done' ? STYLE.neutral : STYLE.success, color: it.status === 'done' ? STYLE.text : STYLE.onAccent, border: it.status === 'done' ? STYLE.borderL1 : 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
649
- 'data-testid': `todo-done-${it.id}`,
650
- },
651
- it.status === 'done' ? 'Undo' : 'Done',
652
- ),
653
- React.createElement(
654
- 'button',
655
- {
656
- onClick: () => startEdit(it),
657
- style: { background: STYLE.brand, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
658
- 'data-testid': `todo-edit-${it.id}`,
659
- },
660
- 'Edit',
661
- ),
662
- React.createElement(
663
- 'button',
664
- {
665
- onClick: () => removeTodo(it),
666
- style: { background: STYLE.error, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
667
- 'data-testid': `todo-remove-${it.id}`,
668
- },
669
- 'Remove',
670
- ),
671
- ),
672
- ),
673
- ),
674
- ),
216
+ filtered.length===0 ? React.createElement('div',{className:'memx-empty'},
217
+ React.createElement('div',{style:{fontWeight:600, color:'var(--dsw-alias-label-primary)'}}, entries.length===0?'No pending suggestions':'No results'),
218
+ React.createElement('div',{className:'memx-muted'}, entries.length===0?'Model proposals will appear here for your approval.':'Try a different filter.'),
219
+ ) : React.createElement('div',{className:'memx-grid'},
220
+ ...filtered.map((e:any)=>{
221
+ const number = entries.indexOf(e)+1
222
+ return React.createElement('div',{key:number, className:'memx-card'},
223
+ React.createElement('div',{className:'memx-cardhead'},
224
+ React.createElement('span',{className:'memx-chip'}, `#${number} · ${e.target}`),
225
+ e.reason ? React.createElement('span',{className:'memx-sm'}, e.reason.slice(0,80)) : null,
226
+ ),
227
+ React.createElement('div',{className:'memx-pre'}, e.content),
228
+ e.reason ? React.createElement('div',{className:'memx-sm'}, `Reason: ${e.reason}`) : null,
229
+ React.createElement('textarea',{value:edits[number]??'', placeholder:'Edit content before approve (optional)', onChange:(ev:any)=>setEdits(prev=>({...prev,[number]:ev.target.value})), className:'memx-textarea', 'aria-label':'Edit before approve', rows:2}),
230
+ React.createElement('div',{className:'memx-actions'},
231
+ React.createElement('button',{onClick:()=>decide('approve',number), 'data-testid':`approve-${number}`, className:'memx-btn memx-btn-primary'}, 'Approve'),
232
+ React.createElement('button',{onClick:()=>decide('reject',number), 'data-testid':`reject-${number}`, className:'memx-btn'}, 'Reject'),
233
+ React.createElement('button',{onClick:()=>decide('archive',number), 'data-testid':`archive-${number}`, className:'memx-btn'}, 'Archive'),
675
234
  ),
235
+ )
236
+ })
237
+ ),
238
+ msg ? React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}}, msg) : null,
676
239
  )
677
240
  }
678
241
 
679
- function SkillsView({ ctx }: { ctx: any }): React.ReactElement {
680
- const rpc = useRpc(ctx)
681
- const [entries, setEntries] = React.useState<any[]>([])
682
- const [loading, setLoading] = React.useState(true)
683
- const [msg, setMsg] = React.useState<string>('')
684
-
685
- const load = React.useCallback(async () => {
686
- setLoading(true)
242
+ // ── Todos ──────────────────────────────────────────────────────────
243
+ function TodosView({ ctx }: {ctx:any}): React.ReactElement {
244
+ const rpc=useRpc(ctx)
245
+ const [items,setItems]=React.useState<any[]>([])
246
+ const [loading,setLoading]=React.useState(true)
247
+ const [msg,setMsg]=React.useState('')
248
+ const [target,setTarget]=React.useState<string>('all')
249
+ const [showAll,setShowAll]=React.useState(false)
250
+ const [showPast,setShowPast]=React.useState(false)
251
+ const [showExpired,setShowExpired]=React.useState(false)
252
+ const [q,setQ]=React.useState('')
253
+ const [draftContent,setDraftContent]=React.useState('')
254
+ const [draftTarget,setDraftTarget]=React.useState<string>('work')
255
+ const [draftDue,setDraftDue]=React.useState('')
256
+ const [draftQuadrant,setDraftQuadrant]=React.useState<string>('')
257
+ const [draftCat,setDraftCat]=React.useState('')
258
+ const [openAdd,setOpenAdd]=React.useState(true)
259
+ const [editId,setEditId]=React.useState<string|null>(null)
260
+ const [editContent,setEditContent]=React.useState('')
261
+ const [editDue,setEditDue]=React.useState('')
262
+ const [editQuadrant,setEditQuadrant]=React.useState('')
263
+ const [editStatus,setEditStatus]=React.useState('')
264
+ const [editCat,setEditCat]=React.useState('')
265
+
266
+ const load=React.useCallback(async()=>{
267
+ setLoading(true); setMsg('')
268
+ try{
269
+ const res:any=await rpc('todo.list',{ target: target!=='all'?target:undefined, opts:{ all:showAll, past:showPast, expired:showExpired } })
270
+ setItems(Array.isArray(res?.items)?res.items:[])
271
+ if(res?.hint) setMsg(res.hint)
272
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
273
+ },[rpc,target,showAll,showPast,showExpired])
274
+ React.useEffect(()=>{ load() },[load])
275
+
276
+ const filtered = React.useMemo(()=>{
277
+ const t=q.trim().toLowerCase(); if(!t) return items
278
+ return items.filter((it:any)=> `${it.text} ${it.target} ${it.cat} ${it.status}`.toLowerCase().includes(t))
279
+ },[items,q])
280
+
281
+ const addTodo=React.useCallback(async()=>{
282
+ const content=draftContent.trim(); if(!content){ setMsg('content required'); return }
687
283
  setMsg('')
688
- try {
689
- const res: any = await rpc('skills.list', {})
690
- if (res?.ok) {
691
- setEntries(Array.isArray(res.entries) ? res.entries : [])
692
- if (res.entries.length === 0) setMsg('No skills found (maestro-skills not installed or empty)')
693
- } else {
694
- setMsg(`load failed: ${res?.error ?? 'unknown'}`)
695
- }
696
- } catch (e: any) {
697
- setMsg(`error: ${e?.message ?? String(e)}`)
698
- } finally {
699
- setLoading(false)
700
- }
701
- }, [rpc])
702
-
703
- React.useEffect(() => {
704
- load()
705
- }, [load])
706
-
707
- if (loading) return React.createElement('div', null, 'Loading skills…')
708
-
709
- return React.createElement(
710
- 'div',
711
- null,
712
- React.createElement(
713
- 'div',
714
- { style: { opacity: 0.7, marginBottom: 8 } },
715
- `${entries.length} skills (read-only browser, metadata/origin only — maestro-skills discovery unchanged)`,
284
+ try{
285
+ const res:any=await rpc('todo.mutate',{ action:'add', target:draftTarget, content, due:draftDue||undefined, quadrant:draftQuadrant||undefined, cat:draftCat||undefined })
286
+ if(res?.ok){ setDraftContent(''); setDraftDue(''); setDraftQuadrant(''); setDraftCat(''); setMsg(`added (id: ${res.id})`); await load() }
287
+ else setMsg(`add failed: ${res?.message??res?.error??'unknown'}`)
288
+ } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
289
+ },[rpc,draftContent,draftTarget,draftDue,draftQuadrant,draftCat,load])
290
+
291
+ const doneTodo=React.useCallback(async(item:any)=>{
292
+ try{ const res:any=await rpc('todo.mutate',{action:'done', target:item.target, id:item.id}); if(res?.ok){ setMsg(res.message??'done'); await load() } else setMsg(`done failed: ${res?.message??res?.error}`) } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
293
+ },[rpc,load])
294
+ const undoTodo=React.useCallback(async(item:any)=>{
295
+ try{ const res:any=await rpc('todo.mutate',{action:'update', target:item.target, id:item.id, status:'pending'}); if(res?.ok){ setMsg(res.message??'undone'); await load() } else setMsg(`undo failed: ${res?.message??res?.error}`) } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
296
+ },[rpc,load])
297
+ const removeTodo=React.useCallback(async(item:any)=>{
298
+ try{ const res:any=await rpc('todo.mutate',{action:'remove', target:item.target, id:item.id}); if(res?.ok){ setMsg(res.message??'removed'); await load() } else setMsg(`remove failed: ${res?.message??res?.error}`) } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
299
+ },[rpc,load])
300
+ const startEdit=(item:any)=>{ setEditId(item.id); setEditContent(item.text??''); setEditDue(item.due??''); setEditQuadrant(item.quadrant??''); setEditStatus(item.status??'pending'); setEditCat(item.cat??'') }
301
+ const saveEdit=React.useCallback(async(item:any)=>{
302
+ try{
303
+ const patch:any={}
304
+ if(editContent!==item.text) patch.content=editContent
305
+ if(editQuadrant!==(item.quadrant??'')) patch.quadrant=editQuadrant||null
306
+ if(editDue!==(item.due??'')) patch.due=editDue||null
307
+ if(editStatus!==item.status) patch.status=editStatus
308
+ if(editCat!==(item.cat??'')) patch.cat=editCat||null
309
+ const res:any=await rpc('todo.mutate',{ action:'update', target:item.target, id:item.id, ...patch })
310
+ if(res?.ok){ setEditId(null); setMsg(res.message??'updated'); await load() } else setMsg(`update failed: ${res?.message??res?.error}`)
311
+ } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
312
+ },[rpc,editContent,editDue,editQuadrant,editStatus,editCat,load])
313
+
314
+ return React.createElement('div',{className:'memx-panel'},
315
+ React.createElement('div',{className:'memx-toolbar'},
316
+ React.createElement('div',{className:'memx-pills'}, (['all','life','work','project','daily'] as const).map(k=> React.createElement('button',{key:k, onClick:()=>setTarget(k), 'aria-pressed':target===k, className:'memx-pill', 'data-testid':`todo-target-${k}`}, k))),
317
+ React.createElement('div',{className:'memx-search', role:'search', 'aria-label':'Filter todos'}, React.createElement(Ico,{d:ICO.search}), React.createElement('input',{value:q, onChange:(e:any)=>setQ(e.target.value), placeholder:'Filter todos…', 'aria-label':'Filter todos'})),
318
+ ),
319
+ React.createElement('div',{className:'memx-toolbar'},
320
+ React.createElement('label',{style:{display:'flex',gap:6,alignItems:'center'}}, React.createElement('input',{type:'checkbox', checked:showAll, onChange:(e:any)=>setShowAll(e.target.checked), 'data-testid':'todo-all'}), 'all'),
321
+ React.createElement('label',{style:{display:'flex',gap:6,alignItems:'center'}}, React.createElement('input',{type:'checkbox', checked:showPast, onChange:(e:any)=>setShowPast(e.target.checked), 'data-testid':'todo-past'}), 'past'),
322
+ React.createElement('label',{style:{display:'flex',gap:6,alignItems:'center'}}, React.createElement('input',{type:'checkbox', checked:showExpired, onChange:(e:any)=>setShowExpired(e.target.checked), 'data-testid':'todo-expired'}), 'expired'),
323
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'data-testid':'todo-refresh'}, React.createElement(Ico,{d:ICO.refresh}), 'Refresh'),
324
+ ),
325
+ React.createElement('div',{className:'memx-form'},
326
+ React.createElement('button',{onClick:()=>setOpenAdd(v=>!v), className:'memx-btn memx-btn-ghost', style:{justifyContent:'space-between'}, 'aria-expanded':openAdd},
327
+ React.createElement('span',{style:{display:'flex',gap:8,alignItems:'center'}}, React.createElement(Ico,{d:ICO.plus}), 'Add todo'),
328
+ React.createElement('span',{className:'memx-muted'}, openAdd?'Hide':'Show'),
329
+ ),
330
+ openAdd ? React.createElement(React.Fragment,null,
331
+ React.createElement('textarea',{value:draftContent, placeholder:'Todo content', onChange:(e:any)=>setDraftContent(e.target.value), className:'memx-textarea', 'aria-label':'Todo content', 'data-testid':'todo-add-content'}),
332
+ React.createElement('div',{className:'memx-formrow'},
333
+ React.createElement('select',{value:draftTarget, onChange:(e:any)=>setDraftTarget(e.target.value), className:'memx-select', 'aria-label':'Todo target', 'data-testid':'todo-add-target'}, (['life','work','project','daily'] as const).map(t=> React.createElement('option',{key:t,value:t},t))),
334
+ React.createElement('select',{value:draftQuadrant, onChange:(e:any)=>setDraftQuadrant(e.target.value), className:'memx-select', 'aria-label':'Quadrant', 'data-testid':'todo-add-quadrant'},
335
+ React.createElement('option',{value:''},'quadrant'), React.createElement('option',{value:'q1'},'q1'), React.createElement('option',{value:'q2'},'q2'), React.createElement('option',{value:'q3'},'q3'), React.createElement('option',{value:'q4'},'q4')),
336
+ React.createElement('input',{value:draftDue, placeholder:'due YYYY-MM-DD', onChange:(e:any)=>setDraftDue(e.target.value), className:'memx-select', 'aria-label':'Due date YYYY-MM-DD', 'data-testid':'todo-add-due'}),
337
+ React.createElement('input',{value:draftCat, placeholder:'cat', onChange:(e:any)=>setDraftCat(e.target.value), className:'memx-select', 'aria-label':'Category', 'data-testid':'todo-add-cat'}),
338
+ ),
339
+ React.createElement('button',{onClick:addTodo, className:'memx-btn memx-btn-primary', 'data-testid':'todo-add-btn'}, 'Add'),
340
+ ) : null,
716
341
  ),
717
- entries.length === 0
718
- ? React.createElement('div', { style: { opacity: 0.7 } }, msg || 'No skills')
719
- : React.createElement(
720
- 'div',
721
- null,
722
- ...entries.map((e: any) =>
723
- React.createElement(
724
- 'div',
725
- { key: e.name, style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 } },
726
- React.createElement('div', { style: { fontWeight: 600 } }, e.name),
727
- React.createElement('div', { style: { opacity: 0.7 } }, `[${e.origin}] ${e.path}`),
728
- React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, e.description),
729
- e.metadata && Object.keys(e.metadata).length > 0
730
- ? React.createElement(
731
- 'div',
732
- { style: { opacity: 0.6, marginTop: 4 } },
733
- `metadata: ${Object.entries(e.metadata)
734
- .map(([k, v]) => `${k}=${String(v).slice(0, 60)}`)
735
- .join(', ')}`,
736
- )
737
- : null,
342
+ msg ? React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}}, msg) : null,
343
+ loading ? React.createElement('div',{className:'memx-muted'}, 'Loading todos…') : filtered.length===0 ? React.createElement('div',{className:'memx-empty'}, 'No todos — try "all" or change filter.') :
344
+ React.createElement('div',null,
345
+ React.createElement('div',{className:'memx-muted', style:{marginBottom:6}}, `${filtered.length} todos${!showAll && filtered.length===8?' (smart view ≤8)':''}`),
346
+ React.createElement('div',{className:'memx-grid'},
347
+ ...filtered.map((it:any)=> React.createElement('div',{key:it.id, className:'memx-card'},
348
+ React.createElement('div',{style:{display:'flex',gap:6,flexWrap:'wrap'}},
349
+ React.createElement('span',{className:'memx-chip'}, it.target),
350
+ it.quadrant?React.createElement('span',{className:'memx-chip'}, it.quadrant):null,
351
+ it.due?React.createElement('span',{className:'memx-chip'}, `due ${it.due}`):null,
352
+ it.status!=='pending'?React.createElement('span',{className:'memx-chip'}, it.status):null,
353
+ it.cat?React.createElement('span',{className:'memx-chip'}, it.cat):null,
738
354
  ),
739
- ),
355
+ editId===it.id ? React.createElement('div',null,
356
+ React.createElement('textarea',{value:editContent, onChange:(e:any)=>setEditContent(e.target.value), className:'memx-textarea', 'aria-label':'Edit todo content', 'data-testid':`todo-edit-content-${it.id}`}),
357
+ React.createElement('div',{className:'memx-formrow', style:{marginTop:8}},
358
+ React.createElement('select',{value:editQuadrant, onChange:(e:any)=>setEditQuadrant(e.target.value), className:'memx-select', 'aria-label':'Edit quadrant', 'data-testid':`todo-edit-quadrant-${it.id}`}, React.createElement('option',{value:''},'no quadrant'), React.createElement('option',{value:'q1'},'q1'), React.createElement('option',{value:'q2'},'q2'), React.createElement('option',{value:'q3'},'q3'), React.createElement('option',{value:'q4'},'q4')),
359
+ React.createElement('input',{value:editDue, placeholder:'due YYYY-MM-DD', onChange:(e:any)=>setEditDue(e.target.value), className:'memx-select', 'aria-label':'Edit due date', 'data-testid':`todo-edit-due-${it.id}`}),
360
+ React.createElement('select',{value:editStatus, onChange:(e:any)=>setEditStatus(e.target.value), className:'memx-select', 'aria-label':'Edit status', 'data-testid':`todo-edit-status-${it.id}`}, (['pending','doing','done','blocked','cancelled'] as const).map(s=> React.createElement('option',{key:s,value:s},s))),
361
+ React.createElement('input',{value:editCat, placeholder:'cat', onChange:(e:any)=>setEditCat(e.target.value), className:'memx-select', 'aria-label':'Edit category', 'data-testid':`todo-edit-cat-${it.id}`}),
362
+ ),
363
+ React.createElement('div',{className:'memx-actions', style:{marginTop:8}},
364
+ React.createElement('button',{onClick:()=>saveEdit(it), className:'memx-btn memx-btn-primary', 'data-testid':`todo-save-${it.id}`}, 'Save'),
365
+ React.createElement('button',{onClick:()=>setEditId(null), className:'memx-btn', 'data-testid':`todo-cancel-${it.id}`}, 'Cancel'),
366
+ ),
367
+ ) : React.createElement(React.Fragment,null,
368
+ React.createElement('div',{className:'memx-pre'}, it.text),
369
+ React.createElement('div',{className:'memx-actions', style:{marginTop:8}},
370
+ React.createElement('button',{onClick:()=> (it.status==='done'?undoTodo(it):doneTodo(it)), className: it.status==='done'?'memx-btn':'memx-btn memx-btn-primary', 'data-testid':`todo-done-${it.id}`}, it.status==='done'?'Undo':'Done'),
371
+ React.createElement('button',{onClick:()=>startEdit(it), className:'memx-btn', 'data-testid':`todo-edit-${it.id}`}, 'Edit'),
372
+ React.createElement('button',{onClick:()=>removeTodo(it), className:'memx-btn', 'data-testid':`todo-remove-${it.id}`}, 'Remove'),
373
+ ),
374
+ ),
375
+ it.doneAt||it.past?React.createElement('div',{className:'memx-xs'}, [it.doneAt?`done:${it.doneAt}`:'', it.past?`past ${it.day}`:''].filter(Boolean).join(' · ')):null,
376
+ ))
740
377
  ),
741
- msg ? React.createElement('div', { style: { marginTop: 8, color: STYLE.text } }, msg) : null,
742
- React.createElement('button', { onClick: load, style: { marginTop: 8, padding: '4px 8px' }, 'data-testid': 'skills-refresh' }, 'Refresh'),
743
- React.createElement('div', { style: { marginTop: 8, opacity: 0.6 } }, 'Read-only — model suggestions cannot change skills. Future edits, if approved, will require explicit user action + path containment.'),
378
+ ),
744
379
  )
745
380
  }
746
381
 
747
- function MemoryListView({ ctx }: { ctx: any }): React.ReactElement {
748
- const rpc = useRpc(ctx)
749
- const [track, setTrack] = React.useState<string>('key')
750
- const [cwd, setCwd] = React.useState<string>(() => sessionCwd(ctx))
751
- const [entries, setEntries] = React.useState<string[]>([])
752
- const [loading, setLoading] = React.useState(true)
753
- const [msg, setMsg] = React.useState<string>('')
754
-
755
- const needsCwd = track === 'key' || track === 'project'
756
- const load = React.useCallback(async () => {
757
- setLoading(true)
758
- setMsg('')
759
- try {
760
- if (needsCwd && !cwd.trim()) {
761
- setEntries([])
762
- setMsg('cwd required for the key/project track')
763
- setLoading(false)
764
- return
765
- }
766
- const res: any = await rpc('memory.list', { target: track, cwd: needsCwd ? cwd.trim() : undefined })
767
- setEntries(Array.isArray(res?.entries) ? res.entries : [])
768
- } catch (e: any) {
769
- setMsg(`load failed: ${e?.message ?? String(e)}`)
770
- } finally {
771
- setLoading(false)
772
- }
773
- }, [rpc, track, cwd, needsCwd])
774
-
775
- React.useEffect(() => {
776
- load()
777
- }, [load])
778
-
779
- return React.createElement(
780
- 'div',
781
- { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
782
- React.createElement(
783
- 'div',
784
- { className: 'row', style: { alignItems: 'center', gap: 6 } },
785
- React.createElement('span', { className: 'tracklabel' }, 'Track'),
786
- (['memory', 'user', 'key', 'project', 'daily'] as const).map((k) =>
787
- React.createElement(
788
- 'button',
789
- {
790
- key: k,
791
- onClick: () => setTrack(k),
792
- 'aria-pressed': track === k,
793
- className: track === k ? 'control pill pill-active' : 'control pill',
794
- 'data-testid': `mem-track-${k}`,
795
- },
796
- k,
797
- ),
798
- ),
799
- ),
800
- React.createElement(
801
- 'div',
802
- { className: 'row', style: { alignItems: 'center' } },
803
- React.createElement('input', {
804
- value: cwd,
805
- disabled: !needsCwd,
806
- placeholder: needsCwd ? 'cwd (for key/project)' : 'cwd is not used for this track',
807
- onChange: (e: any) => setCwd((e.target as HTMLInputElement).value),
808
- 'data-testid': 'mem-cwd',
809
- className: 'cwd',
810
- }),
811
- React.createElement('button', { onClick: load, className: 'control ghost', 'data-testid': 'mem-refresh' }, 'Refresh'),
382
+ // ── Skills ─────────────────────────────────────────────────────────
383
+ function SkillsView({ ctx }: {ctx:any}): React.ReactElement {
384
+ const rpc=useRpc(ctx)
385
+ const [entries,setEntries]=React.useState<any[]>([])
386
+ const [loading,setLoading]=React.useState(true)
387
+ const [msg,setMsg]=React.useState('')
388
+ const [q,setQ]=React.useState('')
389
+ const load=React.useCallback(async()=>{
390
+ setLoading(true); setMsg('')
391
+ try{ const res:any=await rpc('skills.list',{}); if(res?.ok){ setEntries(Array.isArray(res.entries)?res.entries:[]); if(res.entries.length===0) setMsg('No skills found') } else setMsg(`load failed: ${res?.error??'unknown'}`) }
392
+ catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) } finally{ setLoading(false) }
393
+ },[rpc])
394
+ React.useEffect(()=>{ load() },[load])
395
+ const filtered=React.useMemo(()=>{
396
+ const t=q.trim().toLowerCase(); if(!t) return entries
397
+ return entries.filter((e:any)=> `${e.name} ${e.description} ${e.origin}`.toLowerCase().includes(t))
398
+ },[entries,q])
399
+ if(loading) return React.createElement('div',{className:'memx-muted'},'Loading skills…')
400
+ return React.createElement('div',{className:'memx-panel'},
401
+ React.createElement('div',{className:'memx-toolbar'},
402
+ React.createElement('div',{className:'memx-search', role:'search', 'aria-label':'Filter skills'}, React.createElement(Ico,{d:ICO.search}), React.createElement('input',{value:q, onChange:(e:any)=>setQ(e.target.value), placeholder:'Filter skills…', 'aria-label':'Filter skills'})),
403
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'data-testid':'skills-refresh'}, React.createElement(Ico,{d:ICO.refresh}), 'Refresh'),
812
404
  ),
813
- msg ? React.createElement('div', { className: 'muted' }, msg) : null,
814
- loading
815
- ? React.createElement('div', null, 'Loading memory…')
816
- : entries.length === 0
817
- ? React.createElement('div', { className: 'muted' }, '(no entries)')
818
- : React.createElement(
819
- 'div',
820
- null,
821
- React.createElement('div', { className: 'muted', style: { marginBottom: 6 } }, `${entries.length} entries`),
822
- ...entries.map((e: string, idx: number) =>
823
- React.createElement(
824
- 'div',
825
- { key: idx, className: 'card', style: { marginBottom: 8 } },
826
- React.createElement('div', { style: { whiteSpace: 'pre-wrap' } }, e),
827
- ),
828
- ),
829
- ),
405
+ React.createElement('div',{className:'memx-muted'}, `${filtered.length}/${entries.length} skills — read-only, no mutation`),
406
+ filtered.length===0 ? React.createElement('div',{className:'memx-empty'}, msg||'No skills') :
407
+ React.createElement('div',{className:'memx-grid memx-grid-2'},
408
+ ...filtered.map((e:any)=> React.createElement('div',{key:e.name, className:'memx-card'},
409
+ React.createElement('div',{className:'memx-cardtitle', style:{display:'flex',gap:8,alignItems:'center'}}, React.createElement(Ico,{d:ICO.layers, size:14}), e.name),
410
+ React.createElement('div',{className:'memx-sm'}, `[${e.origin}] ${e.path}`),
411
+ React.createElement('div',{className:'memx-pre-sm'}, e.description),
412
+ e.metadata && Object.keys(e.metadata).length>0 ? React.createElement('div',{className:'memx-xs'}, `metadata: ${Object.entries(e.metadata).map(([k,v])=>`${k}=${String(v).slice(0,40)}`).join(', ')}`) : null,
413
+ ))
414
+ ),
415
+ msg?React.createElement('div',{className:'memx-muted', style:{marginTop:8}},msg):null,
830
416
  )
831
417
  }
832
418
 
833
- function HealthView({ ctx }: { ctx: any }): React.ReactElement {
834
- const [health, setHealth] = React.useState<any>(null)
835
- const [loading, setLoading] = React.useState(true)
836
- const [msg, setMsg] = React.useState('')
837
- const load = React.useCallback(async () => {
419
+ // ── Memory List ────────────────────────────────────────────────────
420
+ function MemoryListView({ ctx }: {ctx:any}): React.ReactElement {
421
+ const rpc=useRpc(ctx)
422
+ const [track,setTrack]=React.useState<string>('key')
423
+ const [cwd,setCwd]=React.useState<string>(()=>sessionCwd(ctx))
424
+ const [entries,setEntries]=React.useState<string[]>([])
425
+ const [loading,setLoading]=React.useState(true)
426
+ const [msg,setMsg]=React.useState('')
427
+ const [q,setQ]=React.useState('')
428
+ const needsCwd=track==='key'||track==='project'
429
+ const load=React.useCallback(async()=>{
838
430
  setLoading(true); setMsg('')
839
- try {
840
- const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
841
- if (!conn?.rpc?.call) throw new Error('RPC not available')
842
- const cwd = (ctx as any)?.sessions?.list?.getSnapshot?.()?.byId?.[(ctx as any)?.sessions?.list?.getSnapshot?.()?.current]?.cwd || ''
843
- const res: any = await conn.rpc.call('/dsh-maestro-memory-health', 'get', { cwd })
844
- const val = res?.ok === true ? res.value : res
845
- if (val && typeof val.project !== 'undefined') setHealth(val)
846
- else setMsg(`unexpected: ${JSON.stringify(val).slice(0,120)}`)
847
- } catch (e: any) { setMsg(`load failed: ${e?.message ?? String(e)}`) }
848
- finally { setLoading(false) }
849
- }, [ctx])
850
- React.useEffect(() => { load() }, [load])
851
- if (loading) return React.createElement('div', null, 'Loading health…')
852
- if (msg) return React.createElement('div', null, React.createElement('div', { style: { color: STYLE.error } }, msg), React.createElement('button', { onClick: load, style: { marginTop: 8 } }, 'Retry'))
853
- if (!health) return React.createElement('div', null, 'No data')
854
- const cov = health.project.coverage as number
855
- const color = cov >= 90 ? STYLE.success : cov >= 50 ? '#e6a23c' : STYLE.error
856
- return React.createElement('div', null,
857
- React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 12 } },
858
- React.createElement('div', { className: 'card', style: { flex: 1, minWidth: 140 } },
859
- React.createElement('div', { className: 'muted' }, 'Project entries'),
860
- React.createElement('div', { style: { fontWeight: 700 } }, String(health.project.total)),
861
- React.createElement('div', { className: 'muted' }, `${health.project.withSummary} with summary`),
862
- ),
863
- React.createElement('div', { className: 'card', style: { flex: 1, minWidth: 140 } },
864
- React.createElement('div', { className: 'muted' }, 'Coverage'),
865
- React.createElement('div', { style: { fontWeight: 700, color } }, `${cov.toFixed(1)}%`),
866
- React.createElement('div', { className: 'muted' }, cov >= 90 ? '✅ PASS' : '❌ FAIL (<90%)'),
867
- ),
868
- React.createElement('div', { className: 'card', style: { flex: 1, minWidth: 140 } },
869
- React.createElement('div', { className: 'muted' }, 'Daily last 7d'),
870
- React.createElement('div', { style: { fontWeight: 600 } }, (health.daily.counts as number[]).join(' · ')),
871
- ),
872
- React.createElement('div', { className: 'card', style: { flex: 1, minWidth: 140 } },
873
- React.createElement('div', { className: 'muted' }, 'Discipline'),
874
- React.createElement('div', { style: { fontWeight: 600 } }, '1.5 avg calls/session'),
875
- React.createElement('div', { className: 'muted' }, 'target 1+ (5/20 zero before fix)'),
431
+ try{
432
+ if(needsCwd && !cwd.trim()){ setEntries([]); setMsg('cwd required for key/project'); setLoading(false); return }
433
+ const res:any=await rpc('memory.list',{ target:track, cwd: needsCwd?cwd.trim():undefined })
434
+ setEntries(Array.isArray(res?.entries)?res.entries:[])
435
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
436
+ },[rpc,track,cwd,needsCwd])
437
+ React.useEffect(()=>{ load() },[load])
438
+
439
+ const filtered=React.useMemo(()=>{
440
+ const t=q.trim().toLowerCase(); if(!t) return entries
441
+ return entries.filter(e=> e.toLowerCase().includes(t))
442
+ },[entries,q])
443
+
444
+ return React.createElement('div',{className:'memx-panel'},
445
+ React.createElement('div',{className:'memx-pills'}, (['memory','user','key','project','daily'] as const).map(k=> React.createElement('button',{key:k, onClick:()=>setTrack(k), 'aria-pressed':track===k, className:'memx-pill', 'data-testid':`mem-track-${k}`}, k))),
446
+ React.createElement('div',{className:'memx-toolbar'},
447
+ React.createElement('div',{className:'memx-field'},
448
+ React.createElement('input',{value:cwd, disabled:!needsCwd, placeholder: needsCwd?'cwd (for key/project)':'cwd not used', onChange:(e:any)=>setCwd((e.target as HTMLInputElement).value), 'aria-label':'CWD for key/project', 'data-testid':'mem-cwd', className:'memx-input'}),
449
+ React.createElement('button',{onClick:load, className:'memx-btn', 'data-testid':'mem-refresh'}, React.createElement(Ico,{d:ICO.refresh, size:14}), 'Refresh'),
876
450
  ),
451
+ React.createElement('div',{className:'memx-search', role:'search', 'aria-label':'Filter entries', style:{maxWidth:240}}, React.createElement(Ico,{d:ICO.search}), React.createElement('input',{value:q, onChange:(e:any)=>setQ(e.target.value), placeholder:'Filter entries…', 'aria-label':'Filter entries'})),
877
452
  ),
878
- health.fiveDim ? React.createElement('div', { className: 'card', style: { marginBottom: 12 } },
879
- React.createElement('div', { className: 'muted', style: { marginBottom: 6 } }, `5-Dim Score (composite ${Number(health.fiveDim.composite).toFixed(1)} = min*0.4+mean*0.6)`),
880
- React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
881
- (['S','R','J','C','Safety'] as const).map((k) => React.createElement('div', { key: k, className: 'card', style: { flex: 1, minWidth: 90, textAlign: 'center' } },
882
- React.createElement('div', { className: 'muted' }, k),
883
- React.createElement('div', { style: { fontWeight: 700 } }, String(health.fiveDim[k])),
884
- )),
885
- React.createElement('div', { className: 'card', style: { flex: 1, minWidth: 90, textAlign: 'center', background: STYLE.active } },
886
- React.createElement('div', { className: 'muted' }, 'Composite'),
887
- React.createElement('div', { style: { fontWeight: 800 } }, String(health.fiveDim.composite)),
453
+ msg?React.createElement('div',{className:'memx-muted'},msg):null,
454
+ loading ? React.createElement('div',{className:'memx-muted'},'Loading memory…') : filtered.length===0 ? React.createElement('div',{className:'memx-empty'}, '(no entries)') :
455
+ React.createElement('div',null,
456
+ React.createElement('div',{className:'memx-muted', style:{marginBottom:6}}, `${filtered.length}/${entries.length} entries`),
457
+ React.createElement('div',{className:'memx-grid'},
458
+ ...filtered.map((e:string, idx:number)=> React.createElement('div',{key:idx, className:'memx-card'}, React.createElement('div',{className:'memx-pre'}, e)) ),
888
459
  ),
889
460
  ),
890
- ) : null,
891
- React.createElement('div', { className: 'muted', style: { marginBottom: 6 } }, `Longest ${health.longest.length} entries`),
892
- ...health.longest.map((it: any, i: number) => React.createElement('div', { key: i, className: 'card', style: { marginBottom: 6, display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'space-between' } },
893
- React.createElement('div', { style: { flex: 1 } },
894
- React.createElement('div', { className: 'muted' }, `${it.len} chars`), React.createElement('div', { style: { whiteSpace: 'pre-wrap' } }, it.preview),
895
- ),
896
- React.createElement('button', {
897
- onClick: async () => {
898
- try {
899
- const conn2 = (ctx as any).connection ?? (ctx as any).get?.('connection')
900
- if (!conn2?.rpc?.call) throw new Error('RPC not available')
901
- const res: any = await conn2.rpc.call('/dsh-maestro-memory-propose', 'add', { content: it.preview, reason: 'promote from Health longest' })
902
- const ok = res?.ok === true ? res.value : res
903
- setMsg(ok?.queued ? `proposed (queue ${ok.queued})` : `proposed: ${JSON.stringify(ok).slice(0,80)}`)
904
- } catch (e: any) { setMsg(`propose failed: ${e?.message ?? String(e)}`) }
905
- },
906
- style: { padding: '4px 8px', background: STYLE.brand, color: STYLE.onAccent, border: 0, borderRadius: 4, cursor: 'pointer', whiteSpace: 'nowrap' },
907
- 'data-testid': `health-propose-${i}`,
908
- }, 'Suggest as KEY'),
909
- )),
910
- msg ? React.createElement('div', { style: { marginTop: 8, color: STYLE.textSec, whiteSpace: 'pre-wrap' } }, msg) : null,
911
- React.createElement('button', { onClick: load, style: { marginTop: 12, padding: '6px 12px' }, 'data-testid': 'health-refresh' }, 'Refresh'),
912
461
  )
913
462
  }
914
463
 
915
- function MemoryView({ ctx }: { ctx: any }): React.ReactElement {
916
- const rpc = useRpc(ctx)
917
- const [tab, setTab] = React.useState<'memory' | 'review' | 'todos' | 'skills' | 'health'>('memory')
918
- const [pending, setPending] = React.useState(0)
919
-
920
- const refreshPending = React.useCallback(async () => {
921
- try {
922
- const res: any = await rpc('status', {})
923
- setPending(typeof res?.queue === 'number' ? res.queue : 0)
924
- } catch {
925
- setPending(0)
926
- }
927
- }, [rpc])
928
-
929
- React.useEffect(() => {
930
- refreshPending()
931
- }, [refreshPending, tab])
932
-
933
- return React.createElement(
934
- 'div',
935
- { className: 'dshmem', style: { padding: 16, color: STYLE.text, display: 'flex', flexDirection: 'column' } },
936
- React.createElement('style', null, MEM_CSS),
937
- React.createElement(
938
- 'div',
939
- { className: 'tabs', role: 'tablist' },
940
- (['memory', 'review', 'todos', 'skills', 'health'] as const).map((k) =>
941
- React.createElement(
942
- 'button',
943
- {
944
- key: k,
945
- role: 'tab',
946
- 'aria-selected': tab === k,
947
- onClick: () => setTab(k),
948
- className: tab === k ? 'tab tab-active' : 'tab',
949
- 'data-testid': `tab-${k}`,
950
- },
951
- k,
952
- k === 'review' && pending > 0
953
- ? React.createElement('span', { className: 'tabbadge', 'data-testid': 'tab-badge-review' }, String(pending))
954
- : null,
955
- ),
464
+ // ── Health ─────────────────────────────────────────────────────────
465
+ function HealthView({ ctx }: {ctx:any}): React.ReactElement {
466
+ const [health,setHealth]=React.useState<any>(null)
467
+ const [loading,setLoading]=React.useState(true)
468
+ const [msg,setMsg]=React.useState('')
469
+ const load=React.useCallback(async()=>{
470
+ setLoading(true); setMsg('')
471
+ try{
472
+ const conn=(ctx as any).connection ?? (ctx as any).get?.('connection')
473
+ if(!conn?.rpc?.call) throw new Error('RPC not available')
474
+ const cwd=(ctx as any)?.sessions?.list?.getSnapshot?.()?.byId?.[(ctx as any)?.sessions?.list?.getSnapshot?.()?.current]?.cwd || ''
475
+ const res:any=await conn.rpc.call('/dsh-maestro-memory-health','get',{cwd})
476
+ const val=res?.ok===true?res.value:res
477
+ if(val && typeof val.project!=='undefined') setHealth(val); else setMsg(`unexpected: ${JSON.stringify(val).slice(0,120)}`)
478
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
479
+ },[ctx])
480
+ React.useEffect(()=>{ load() },[load])
481
+ if(loading) return React.createElement('div',{className:'memx-muted'},'Loading health…')
482
+ if(msg) return React.createElement('div',null, React.createElement('div',{style:{color:'var(--dsw-alias-state-error-primary)'}},msg), React.createElement('button',{onClick:load, className:'memx-btn', style:{marginTop:8}},'Retry'))
483
+ if(!health) return React.createElement('div',{className:'memx-muted'},'No data')
484
+ const cov=health.project.coverage as number
485
+ const covColor = cov>=90 ? 'var(--dsw-alias-state-success-primary)' : cov>=50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)'
486
+ return React.createElement('div',{className:'memx-panel'},
487
+ React.createElement('div',{className:'memx-grid memx-grid-4'},
488
+ React.createElement('div',{className:'memx-card memx-stat'}, React.createElement('div',{className:'memx-kicker'},'Project entries'), React.createElement('div',{className:'memx-statval'}, String(health.project.total)), React.createElement('div',{className:'memx-sm'}, `${health.project.withSummary} with summary`)),
489
+ React.createElement('div',{className:'memx-card memx-stat'}, React.createElement('div',{className:'memx-kicker'},'Coverage'), React.createElement('div',{className:'memx-statval', style:{color:covColor}}, `${cov.toFixed(1)}%`), React.createElement('div',{className:'memx-sm'}, cov>=90?'PASS':'FAIL (<90%)')),
490
+ React.createElement('div',{className:'memx-card memx-stat'}, React.createElement('div',{className:'memx-kicker'},'Daily last 7d'), React.createElement('div',{style:{fontWeight:600}}, (health.daily.counts as number[]).join(' · ')), React.createElement('div',{className:'memx-sm'}, 'entries per day')),
491
+ React.createElement('div',{className:'memx-card memx-stat'}, React.createElement('div',{className:'memx-kicker'},'Discipline'), React.createElement('div',{style:{fontWeight:600}}, '1.5 avg / session'), React.createElement('div',{className:'memx-sm'}, 'target 1+')),
492
+ ),
493
+ health.fiveDim?React.createElement('div',{className:'memx-card'},
494
+ React.createElement('div',{className:'memx-muted', style:{marginBottom:8}}, `5-Dim Score (composite ${Number(health.fiveDim.composite).toFixed(1)} = min×0.4+mean×0.6)`),
495
+ React.createElement('div',{className:'memx-grid memx-grid-4'},
496
+ (['S','R','J','C','Safety'] as const).map(k=> React.createElement('div',{key:k, className:'memx-card', style:{textAlign:'center', background:'var(--dsw-alias-bg-layer-2)'}}, React.createElement('div',{className:'memx-kicker'},k), React.createElement('div',{style:{fontWeight:700}}, String(health.fiveDim[k])))),
497
+ React.createElement('div',{className:'memx-card', style:{textAlign:'center', background:'var(--dsw-alias-interactive-bg-active)', borderColor:'var(--dsw-alias-border-l2)'}}, React.createElement('div',{className:'memx-kicker'},'Composite'), React.createElement('div',{style:{fontWeight:800}}, String(health.fiveDim.composite))),
956
498
  ),
499
+ ):null,
500
+ React.createElement('div',{className:'memx-muted'}, `Longest ${health.longest.length} entries`),
501
+ React.createElement('div',{className:'memx-grid'},
502
+ ...health.longest.map((it:any,i:number)=> React.createElement('div',{key:i, className:'memx-card', style:{flexDirection:'row', alignItems:'center', justifyContent:'space-between', gap:12}},
503
+ React.createElement('div',{style:{flex:1, minWidth:0}}, React.createElement('div',{className:'memx-xs'}, `${it.len} chars`), React.createElement('div',{className:'memx-pre-sm'}, it.preview)),
504
+ React.createElement('button',{onClick: async()=>{
505
+ try{
506
+ const conn2=(ctx as any).connection ?? (ctx as any).get?.('connection'); if(!conn2?.rpc?.call) throw new Error('RPC not available')
507
+ const res:any=await conn2.rpc.call('/dsh-maestro-memory-propose','add',{content:it.preview, reason:'promote from Health longest'}); const ok=res?.ok===true?res.value:res; setMsg(ok?.queued?`proposed (queue ${ok.queued})`:`proposed: ${JSON.stringify(ok).slice(0,80)}`)
508
+ } catch(e:any){ setMsg(`propose failed: ${e?.message??String(e)}`) }
509
+ }, className:'memx-btn memx-btn-primary', 'data-testid':`health-propose-${i}`, style:{whiteSpace:'nowrap', flex:'none'}}, 'Suggest as KEY'),
510
+ )),
957
511
  ),
958
- tab === 'memory'
959
- ? React.createElement(MemoryListView, { ctx })
960
- : tab === 'review'
961
- ? React.createElement(ReviewQueueView, { ctx, onPendingChange: setPending })
962
- : tab === 'todos'
963
- ? React.createElement(TodosView, { ctx })
964
- : tab === 'health'
965
- ? React.createElement(HealthView, { ctx })
966
- : React.createElement(SkillsView, { ctx }),
512
+ msg?React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}},msg):null,
513
+ React.createElement('button',{onClick:load, className:'memx-btn', style:{marginTop:4}, 'data-testid':'health-refresh'}, React.createElement(Ico,{d:ICO.refresh, size:14}), 'Refresh'),
967
514
  )
968
515
  }
969
516
 
970
- export function apply(ctx: any): void {
971
- ctx.effect(() => {
972
- const dispose = ctx.slots.inject('conversation.view', () =>
973
- ctx.slots.register(
974
- {
975
- name: 'conversation.view',
976
- id: 'maestro-memory',
977
- order: 40,
978
- label: () => 'Memory',
517
+ // ── Root ───────────────────────────────────────────────────────────
518
+ function MemoryView({ ctx }: {ctx:any}): React.ReactElement {
519
+ const rpc=useRpc(ctx)
520
+ const [tab,setTab]=React.useState<'memory'|'review'|'todos'|'skills'|'health'>('memory')
521
+ const [pending,setPending]=React.useState(0)
522
+ const refreshPending=React.useCallback(async()=>{
523
+ try{ const res:any=await rpc('status',{}); setPending(typeof res?.queue==='number'?res.queue:0) } catch{ setPending(0) }
524
+ },[rpc])
525
+ React.useEffect(()=>{ refreshPending() },[refreshPending,tab])
526
+
527
+ const tabs: Array<{id:typeof tab; label:string; icon:string; count?:number}> = [
528
+ {id:'memory', label:'Memory', icon:ICO.mem},
529
+ {id:'review', label:'Queue', icon:ICO.inbox, count:pending},
530
+ {id:'todos', label:'Todos', icon:ICO.check},
531
+ {id:'skills', label:'Skills', icon:ICO.layers},
532
+ {id:'health', label:'Health', icon:ICO.activity},
533
+ ]
534
+
535
+ return React.createElement('div',{className:'dshmem memx', style:{padding:'12px 12px 16px', background:'var(--dsw-alias-bg-base)', position:'relative', zIndex:1, isolation:'isolate' as any, minHeight:'100%', width:'100%'} as any},
536
+ React.createElement('style',null, MEM_CSS),
537
+ React.createElement('div',{className:'memx-header'},
538
+ React.createElement('div',{className:'memx-header-main'},
539
+ React.createElement('div',{className:'memx-title'}, React.createElement(Ico,{d:ICO.spark, size:18}), 'Memory'),
540
+ React.createElement('div',{className:'memx-subtitle'}, 'Durable layered memory — five tracks, gated writes, todos & skills'),
541
+ ),
542
+ React.createElement('div',{className:'memx-header-actions'},
543
+ React.createElement('button',{onClick:refreshPending, className:'memx-iconbtn', 'aria-label':'Refresh status', title:'Refresh'}, React.createElement(Ico,{d:ICO.refresh, size:16})),
544
+ ),
545
+ ),
546
+ React.createElement('div',{className:'memx-layout'},
547
+ React.createElement('div',{className:'memx-nav', role:'tablist', 'aria-label':'Memory sections'},
548
+ ...tabs.map(t=> React.createElement('button',{
549
+ key:t.id, role:'tab', id:`tab-${t.id}`, 'aria-selected':tab===t.id, 'aria-controls':`panel-${t.id}`, onClick:()=>setTab(t.id), className:'memx-navitem', 'data-testid':`tab-${t.id}`,
979
550
  },
980
- () => React.createElement(MemoryView, { ctx }),
551
+ React.createElement(Ico,{d:t.icon}), t.label,
552
+ t.count!==undefined && t.count>0 ? React.createElement('span',{className:'memx-badge', 'data-testid':'tab-badge-review'}, String(t.count)) : null,
553
+ t.id==='memory'?React.createElement('span',{className:'memx-navcount'}, '5 tracks'):null,
554
+ )),
981
555
  ),
982
- )
983
- return () => {
984
- if (typeof dispose === 'function') dispose()
985
- }
986
- }, 'maestro-memory: view')
987
-
988
- // Top-level header tab badge: pending-suggestion count, with a soft blink,
989
- // injected into the DSH shell header tab labeled "Memory". DSH projects
990
- // 'conversation.view' tabs as plain string labels (ViewTab.label), so there is
991
- // no React slot to render into — the plugin reaches the shell DOM directly
992
- // (same technique as the mobile nav plugin).
993
- ctx.effect(() => {
994
- const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
995
- if (!conn?.rpc?.call) return () => {}
996
- const BADGE_ATTR = 'data-dshmem-header-badge'
997
- const VIEW_ATTR = 'data-dshmem-memory-tab'
556
+ React.createElement('div',{className:'memx-panel', role:'tabpanel', id:`panel-${tab}`, 'aria-labelledby':`tab-${tab}`},
557
+ tab==='memory'?React.createElement(MemoryListView,{ctx}):
558
+ tab==='review'?React.createElement(ReviewQueueView,{ctx, onPendingChange:setPending}):
559
+ tab==='todos'?React.createElement(TodosView,{ctx}):
560
+ tab==='health'?React.createElement(HealthView,{ctx}):
561
+ React.createElement(SkillsView,{ctx}),
562
+ ),
563
+ ),
564
+ )
565
+ }
998
566
 
999
- const findMemoryTab = (): HTMLElement | null => {
1000
- const tabs = document.querySelectorAll('button[role="tab"]')
1001
- for (const tab of Array.from(tabs)) {
1002
- if (tab.getAttribute(VIEW_ATTR) === '1') return tab as HTMLElement
1003
- }
1004
- for (const tab of Array.from(tabs)) {
1005
- if (tab.closest('.dshmem')) continue
1006
- if ((tab.textContent ?? '').trim() === 'Memory') {
1007
- tab.setAttribute(VIEW_ATTR, '1')
1008
- return tab as HTMLElement
1009
- }
1010
- }
567
+ export function apply(ctx:any): void {
568
+ ctx.effect(()=>{
569
+ const dispose=ctx.slots.inject('conversation.view', ()=> ctx.slots.register({ name:'conversation.view', id:'maestro-memory', order:40, label:()=>'Memory' }, ()=> React.createElement(MemoryView,{ctx})))
570
+ return ()=>{ if(typeof dispose==='function') dispose() }
571
+ },'maestro-memory: view')
572
+
573
+ ctx.effect(()=>{
574
+ const conn=(ctx as any).connection ?? (ctx as any).get?.('connection')
575
+ if(!conn?.rpc?.call) return ()=>{}
576
+ const BADGE_ATTR='data-dshmem-header-badge'
577
+ const VIEW_ATTR='data-dshmem-memory-tab'
578
+ const findMemoryTab=(): HTMLElement|null=>{
579
+ const tabs=document.querySelectorAll('button[role="tab"]')
580
+ for(const tab of Array.from(tabs)){ if(tab.getAttribute(VIEW_ATTR)==='1') return tab as HTMLElement }
581
+ for(const tab of Array.from(tabs)){ if(tab.closest('.memx')) continue; if((tab.textContent??'').trim()==='Memory'){ tab.setAttribute(VIEW_ATTR,'1'); return tab as HTMLElement } }
1011
582
  return null
1012
583
  }
1013
-
1014
- const renderBadge = (count: number): void => {
1015
- const tab = findMemoryTab()
1016
- if (!tab) return
1017
- const existing = tab.querySelector(`[${BADGE_ATTR}]`)
1018
- if (count <= 0) {
1019
- if (existing) existing.remove()
1020
- return
1021
- }
1022
- if (existing && existing.textContent === String(count)) return
1023
- if (existing) existing.remove()
1024
- const badge = document.createElement('span')
1025
- badge.setAttribute(BADGE_ATTR, '')
1026
- badge.className = 'dshmem-header-badge'
1027
- badge.textContent = String(count)
1028
- tab.appendChild(badge)
584
+ const renderBadge=(count:number): void=>{
585
+ const tab=findMemoryTab(); if(!tab) return
586
+ const existing=tab.querySelector(`[${BADGE_ATTR}]`)
587
+ if(count<=0){ if(existing) existing.remove(); return }
588
+ if(existing && (existing.textContent===String(count))) return
589
+ if(existing) existing.remove()
590
+ const badge=document.createElement('span'); badge.setAttribute(BADGE_ATTR,''); badge.className='dshmem-header-badge'; badge.textContent=String(count); tab.appendChild(badge)
1029
591
  }
1030
-
1031
- let styleEl = document.getElementById('dshmem-header-style') as HTMLStyleElement | null
1032
- if (!styleEl) {
1033
- styleEl = document.createElement('style')
1034
- styleEl.id = 'dshmem-header-style'
1035
- styleEl.textContent = HEADER_BADGE_CSS
1036
- document.head.appendChild(styleEl)
1037
- }
1038
-
1039
- let disposed = false
1040
- let lastCount = 0
1041
- const refresh = async (): Promise<void> => {
1042
- if (disposed) return
1043
- try {
1044
- const result: any = await conn.rpc.call(RPC_CHANNEL, 'status', {})
1045
- const value = result?.ok === true ? result.value : null
1046
- lastCount = typeof value?.queue === 'number' ? value.queue : 0
1047
- renderBadge(lastCount)
1048
- } catch {
1049
- lastCount = 0
1050
- renderBadge(0)
1051
- }
592
+ let styleEl=document.getElementById('dshmem-header-style') as HTMLStyleElement|null
593
+ if(!styleEl){ styleEl=document.createElement('style'); styleEl.id='dshmem-header-style'; styleEl.textContent=HEADER_BADGE_CSS; document.head.appendChild(styleEl) }
594
+ let disposed=false; let lastCount=0
595
+ const refresh=async():Promise<void>=>{
596
+ if(disposed) return
597
+ try{ const result:any=await conn.rpc.call(RPC_CHANNEL,'status',{}); const value=result?.ok===true?result.value:null; lastCount= typeof value?.queue==='number'?value.queue:0; renderBadge(lastCount) } catch{ lastCount=0; renderBadge(0) }
1052
598
  }
1053
-
1054
599
  void refresh()
1055
- const timer = window.setInterval(() => {
1056
- void refresh()
1057
- }, 8000)
1058
-
1059
- const observer = new MutationObserver(() => {
1060
- if (disposed) return
1061
- const tab = findMemoryTab()
1062
- if (!tab) return
1063
- const hasBadge = tab.querySelector(`[${BADGE_ATTR}]`)
1064
- if (lastCount > 0 && !hasBadge) renderBadge(lastCount)
1065
- else if (lastCount <= 0 && hasBadge) renderBadge(0)
600
+ const timer=window.setInterval(()=>{ void refresh() },8000)
601
+ const observer=new MutationObserver(()=>{
602
+ if(disposed) return; const tab=findMemoryTab(); if(!tab) return
603
+ const hasBadge=tab.querySelector(`[${BADGE_ATTR}]`)
604
+ if(lastCount>0 && !hasBadge) renderBadge(lastCount); else if(lastCount<=0 && hasBadge) renderBadge(0)
1066
605
  })
1067
- observer.observe(document.body, { childList: true, subtree: true })
1068
-
1069
- return () => {
1070
- disposed = true
1071
- window.clearInterval(timer)
1072
- observer.disconnect()
1073
- styleEl?.remove()
1074
- const tab = findMemoryTab()
1075
- if (tab) tab.querySelector(`[${BADGE_ATTR}]`)?.remove()
1076
- }
1077
- }, 'maestro-memory: header-badge')
606
+ observer.observe(document.body,{childList:true,subtree:true})
607
+ return ()=>{ disposed=true; window.clearInterval(timer); observer.disconnect(); styleEl?.remove(); const tab=findMemoryTab(); if(tab) tab.querySelector(`[${BADGE_ATTR}]`)?.remove() }
608
+ },'maestro-memory: header-badge')
1078
609
  }