@ddtcorex/dsh-maestro-memory 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1078 +1,602 @@
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
+ /* legacy contract for tests — inheritance via .dshmem */
48
34
  .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); }
35
+ .dshmem button { font: inherit; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease, opacity .15s ease; }
54
36
  .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; }
37
+ .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; }
38
+ .memx { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; }
39
+ .memx * { box-sizing: border-box; }
40
+ .memx button { font: inherit; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease, opacity .15s ease; }
41
+ .memx button:active { transform: translateY(.5px); }
42
+ .memx button:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-active); outline-offset: 1px; }
43
+ .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; }
44
+ .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); }
45
+ .memx textarea { resize: vertical; }
46
+ @media (prefers-reduced-motion: reduce) { .memx button, .memx-card, .dshmem-header-badge { transition: none !important; animation: none !important; } }
47
+
48
+ /* header refresh ngang hàng title (yêu cầu) */
49
+ .memx-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin-bottom:14px; }
50
+ .memx-title { font-size:16px; font-weight:700; letter-spacing:-.01em; display:flex; align-items:center; gap:8px; }
51
+ .memx-title svg { flex:none; }
52
+ .memx-subtitle { color: var(--dsw-alias-label-secondary); font-size:12px; margin-top:2px; }
53
+ .memx-header-main { flex:1 1 auto; min-width:0; }
54
+ .memx-header-actions { flex:0 0 auto; display:flex; gap:8px; align-items:center; margin-left:auto; }
55
+ .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; }
56
+ @media (max-width:1023px) { .memx-search { max-width:100%; flex-basis:100%; } }
57
+ .memx-search input { border:none; background:transparent; padding:0; flex:1; min-width:0; box-shadow:none !important; }
58
+ .memx-search:focus-within { border-color:var(--dsw-alias-border-l2); box-shadow:0 0 0 2px var(--dsw-alias-interactive-bg-active); }
59
+ .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); }
60
+ .memx-iconbtn:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-2); }
61
+ .memx-iconbtn[aria-busy="true"] { opacity:.6; pointer-events:none; }
62
+
63
+ /* layout align breakpoint with dsh-maestro-mobile (1023px) */
64
+ .memx-layout { display:grid; gap:14px; }
65
+ @media (min-width: 1024px) { .memx-layout { grid-template-columns: 200px 1fr; align-items:start; } }
66
+ /* nav */
67
+ .memx-nav { display:flex; gap:8px; overflow-x:auto; scrollbar-width:none; -webkit-overflow-scrolling:touch; padding-bottom:2px; }
68
+ .memx-nav::-webkit-scrollbar { display:none; }
69
+ @media (min-width: 1024px) { .memx-nav { flex-direction:column; position:sticky; top:8px; overflow:visible; gap:4px; } }
70
+ .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; }
71
+ .memx-navitem:hover { background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l1); }
72
+ .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; }
73
+ .memx-navitem svg { width:16px; height:16px; flex:none; }
74
+ .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; }
75
+ .memx-navcount { margin-left:auto; color:var(--dsw-alias-label-secondary); font-size:11px; }
76
+
77
+ /* content */
78
+ .memx-panel { min-width:0; display:flex; flex-direction:column; gap:12px; }
79
+ .memx-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
80
+ .memx-pills { display:flex; gap:8px; flex-wrap:wrap; }
81
+ .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; }
82
+ .memx-pill:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l2); }
83
+ .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); }
84
+ .memx-field { display:flex; gap:8px; align-items:center; flex-wrap:wrap; flex:1 1 100%; min-width:0; }
85
+ .memx-input { height:44px; padding:0 10px; flex:1 1 140px; min-width:0; }
86
+ @media (max-width:480px) { .memx-field { flex-direction:column; align-items:stretch; } .memx-input { flex-basis:auto; width:100%; } }
87
+ .memx-input:disabled { opacity:.5; cursor:not-allowed; }
88
+ .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; }
89
+ .memx-btn:hover { border-color:var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-2); }
90
+ .memx-btn-primary { background:#06c; color:#fff; border-color:#06c; }
91
+ .memx-btn-primary:hover { background:#05a; border-color:#05a; color:#fff; }
92
+ .memx-btn-ghost { background:transparent; }
93
+ .memx-btn-danger { background:var(--dsw-alias-state-error-primary); color:#fff; border-color:transparent; }
94
+ .memx-btn:disabled { opacity:.5; cursor:not-allowed; }
95
+
96
+ /* bento / cards */
97
+ .memx-grid { display:grid; gap:10px; }
98
+ @media (min-width: 640px) { .memx-grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
99
+ @media (min-width: 1024px) { .memx-grid-4 { grid-template-columns: repeat(4, minmax(0,1fr)); } }
100
+ .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; }
101
+ .memx-card:hover { border-color:var(--dsw-alias-border-l2); }
102
+ .memx-cardhead { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
103
+ .memx-kicker { text-transform:uppercase; letter-spacing:.04em; font-size:11px; color:var(--dsw-alias-label-secondary); font-weight:600; }
104
+ .memx-cardtitle { font-weight:600; }
105
+ .memx-muted { color:var(--dsw-alias-label-secondary); }
106
+ .memx-sm { font-size:12px; color:var(--dsw-alias-label-secondary); }
107
+ .memx-xs { font-size:11px; color:var(--dsw-alias-label-secondary); }
108
+ .memx-pre { white-space:pre-wrap; word-break:break-word; }
109
+ .memx-pre-sm { white-space:pre-wrap; word-break:break-word; font-size:12px; }
110
+ .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); }
111
+ .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); }
112
+ .memx-stat { text-align:left; }
113
+ .memx-statval { font-size:20px; font-weight:800; letter-spacing:-.02em; }
114
+ .memx-actions { display:flex; gap:8px; flex-wrap:wrap; }
115
+ @media (max-width: 639px) { .memx-actions button { flex:1; min-width:0; } }
116
+
117
+ /* forms inline */
118
+ .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; }
119
+ .memx-formrow { display:flex; gap:8px; flex-wrap:wrap; }
120
+ .memx-formrow > * { flex:1; min-width:120px; }
121
+ .memx-textarea { min-height:72px; padding:8px 10px; width:100%; }
122
+ .memx-select, .memx-date { height:44px; padding:0 8px; }
123
+ @media (max-width:1023px) { .memx { padding:12px 12px calc(16px + env(safe-area-inset-bottom,0px)) !important; } .memx-layout { gap:12px; } }
124
+ @media (min-width:1440px) { .memx { max-width:1280px; margin:0 auto; } }
83
125
  `
84
126
 
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
127
  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; } }
128
+ .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; }
129
+ @keyframes dshmem-blink { 0%,100%{opacity:1} 50%{opacity:.3} }
91
130
  `
92
131
 
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
132
  function sessionCwd(ctx: any): string {
99
133
  try {
100
134
  const snap = ctx?.sessions?.list?.getSnapshot?.()
101
135
  const id: string | undefined = snap?.current
102
136
  if (!id) return ''
103
137
  return (snap?.byId?.[id]?.cwd as string) ?? ''
104
- } catch {
105
- return ''
106
- }
138
+ } catch { return '' }
107
139
  }
108
140
 
109
141
  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
- )
142
+ return React.useCallback((endpoint: string, payload: any) => {
143
+ const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
144
+ if (!conn?.rpc?.call) return Promise.reject(new Error('RPC not available'))
145
+ return conn.rpc.call(RPC_CHANNEL, endpoint, payload).then((result: any) => {
146
+ if (result?.ok === true) return result.value
147
+ const message = typeof result?.error?.message === 'string' ? result.error.message : 'RPC request failed'
148
+ throw new Error(message)
149
+ })
150
+ }, [ctx])
122
151
  }
123
152
 
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
- )
153
+ // tiny inline icons (no emoji) stroke currentColor
154
+ function Ico({ d, size = 16, label }: { d: string; size?: number; label?: string }) {
155
+ return React.createElement('span', { 'aria-hidden': label ? undefined : true, 'aria-label': label, style: { display:'inline-flex', lineHeight:0 } },
156
+ 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,
157
+ React.createElement('path', { d })))
158
+ }
159
+ const ICO = {
160
+ 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',
161
+ inbox: 'M4 6h16v12H4z M4 6l8 7 8-7',
162
+ check: 'M9 12l2 2 4-4 M4 6h16v12H4z',
163
+ layers: 'M12 3l9 5-9 5-9-5 9-5z M3 12l9 5 9-5 M3 17l9 5 9-5',
164
+ activity: 'M3 12h4l3-8 4 16 3-8h4',
165
+ search: 'M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14z M18 18l-3.5-3.5',
166
+ refresh: 'M21 12a9 9 0 1 1-2.6-6.4 M21 3v6h-6',
167
+ plus: 'M12 5v14 M5 12h14',
168
+ spark: 'M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5z',
240
169
  }
241
170
 
242
- function TodosView({ ctx }: { ctx: any }): React.ReactElement {
171
+ // ── Review ─────────────────────────────────────────────────────────
172
+ function ReviewQueueView({ ctx, onPendingChange }: { ctx:any; onPendingChange?:(n:number)=>void }) : React.ReactElement {
243
173
  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('')
174
+ const [entries,setEntries]=React.useState<any[]>([])
175
+ const [loading,setLoading]=React.useState(true)
176
+ const [edits,setEdits]=React.useState<Record<number,string>>({})
177
+ const [msg,setMsg]=React.useState('')
178
+ const [q,setQ]=React.useState('')
262
179
 
263
- const load = React.useCallback(async () => {
180
+ const load = React.useCallback(async()=>{
264
181
  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])
182
+ try{ const res:any=await rpc('queue.list',{}); const list=Array.isArray(res?.entries)?res.entries:[]; setEntries(list); onPendingChange?.(list.length) }
183
+ catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
184
+ },[rpc,onPendingChange])
185
+ React.useEffect(()=>{ load() },[load])
293
186
 
294
- const addTodo = React.useCallback(async () => {
295
- const content = draftContent.trim()
296
- if (!content) {
297
- setMsg('content required')
298
- return
299
- }
187
+ const decide = React.useCallback(async(action:'approve'|'reject'|'archive', index:number)=>{
300
188
  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',
189
+ const payload:any={ action, indices:[index] }
190
+ if(action==='approve' && edits[index]!==undefined && edits[index].trim()!=='') payload.edits={ [String(index)]: edits[index] }
191
+ 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'}`) }
192
+ catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
193
+ },[rpc,edits,load])
194
+
195
+ const filtered = React.useMemo(()=>{
196
+ const t=q.trim().toLowerCase(); if(!t) return entries
197
+ return entries.filter((e:any)=> `${e.target} ${e.content} ${e.reason}`.toLowerCase().includes(t))
198
+ },[entries,q])
199
+
200
+ if(loading) return React.createElement('div', {className:'memx-muted'}, 'Loading queue…')
201
+ return React.createElement('div', {className:'memx-panel'},
202
+ React.createElement('div', {className:'memx-toolbar'},
203
+ React.createElement('div', {className:'memx-search', role:'search', 'aria-label':'Filter queue'},
204
+ 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
205
  ),
206
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'aria-label':'Refresh queue'}, React.createElement(Ico,{d:ICO.refresh}), ' Refresh'),
207
+ React.createElement('span',{className:'memx-muted'}, `${filtered.length}/${entries.length} pending`),
538
208
  ),
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
- ),
209
+ filtered.length===0 ? React.createElement('div',{className:'memx-empty'},
210
+ React.createElement('div',{style:{fontWeight:600, color:'var(--dsw-alias-label-primary)'}}, entries.length===0?'No pending suggestions':'No results'),
211
+ React.createElement('div',{className:'memx-muted'}, entries.length===0?'Model proposals will appear here for your approval.':'Try a different filter.'),
212
+ ) : React.createElement('div',{className:'memx-grid'},
213
+ ...filtered.map((e:any)=>{
214
+ const number = entries.indexOf(e)+1
215
+ return React.createElement('div',{key:number, className:'memx-card'},
216
+ React.createElement('div',{className:'memx-cardhead'},
217
+ React.createElement('span',{className:'memx-chip'}, `#${number} · ${e.target}`),
218
+ e.reason ? React.createElement('span',{className:'memx-sm'}, e.reason.slice(0,80)) : null,
219
+ ),
220
+ React.createElement('div',{className:'memx-pre'}, e.content),
221
+ e.reason ? React.createElement('div',{className:'memx-sm'}, `Reason: ${e.reason}`) : null,
222
+ 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}),
223
+ React.createElement('div',{className:'memx-actions'},
224
+ React.createElement('button',{onClick:()=>decide('approve',number), 'data-testid':`approve-${number}`, className:'memx-btn memx-btn-primary'}, 'Approve'),
225
+ React.createElement('button',{onClick:()=>decide('reject',number), 'data-testid':`reject-${number}`, className:'memx-btn'}, 'Reject'),
226
+ React.createElement('button',{onClick:()=>decide('archive',number), 'data-testid':`archive-${number}`, className:'memx-btn'}, 'Archive'),
675
227
  ),
228
+ )
229
+ })
230
+ ),
231
+ msg ? React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}}, msg) : null,
676
232
  )
677
233
  }
678
234
 
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)
235
+ // ── Todos ──────────────────────────────────────────────────────────
236
+ function TodosView({ ctx }: {ctx:any}): React.ReactElement {
237
+ const rpc=useRpc(ctx)
238
+ const [items,setItems]=React.useState<any[]>([])
239
+ const [loading,setLoading]=React.useState(true)
240
+ const [msg,setMsg]=React.useState('')
241
+ const [target,setTarget]=React.useState<string>('all')
242
+ const [showAll,setShowAll]=React.useState(false)
243
+ const [showPast,setShowPast]=React.useState(false)
244
+ const [showExpired,setShowExpired]=React.useState(false)
245
+ const [q,setQ]=React.useState('')
246
+ const [draftContent,setDraftContent]=React.useState('')
247
+ const [draftTarget,setDraftTarget]=React.useState<string>('work')
248
+ const [draftDue,setDraftDue]=React.useState('')
249
+ const [draftQuadrant,setDraftQuadrant]=React.useState<string>('')
250
+ const [draftCat,setDraftCat]=React.useState('')
251
+ const [openAdd,setOpenAdd]=React.useState(true)
252
+ const [editId,setEditId]=React.useState<string|null>(null)
253
+ const [editContent,setEditContent]=React.useState('')
254
+ const [editDue,setEditDue]=React.useState('')
255
+ const [editQuadrant,setEditQuadrant]=React.useState('')
256
+ const [editStatus,setEditStatus]=React.useState('')
257
+ const [editCat,setEditCat]=React.useState('')
258
+
259
+ const load=React.useCallback(async()=>{
260
+ setLoading(true); setMsg('')
261
+ try{
262
+ const res:any=await rpc('todo.list',{ target: target!=='all'?target:undefined, opts:{ all:showAll, past:showPast, expired:showExpired } })
263
+ setItems(Array.isArray(res?.items)?res.items:[])
264
+ if(res?.hint) setMsg(res.hint)
265
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
266
+ },[rpc,target,showAll,showPast,showExpired])
267
+ React.useEffect(()=>{ load() },[load])
268
+
269
+ const filtered = React.useMemo(()=>{
270
+ const t=q.trim().toLowerCase(); if(!t) return items
271
+ return items.filter((it:any)=> `${it.text} ${it.target} ${it.cat} ${it.status}`.toLowerCase().includes(t))
272
+ },[items,q])
273
+
274
+ const addTodo=React.useCallback(async()=>{
275
+ const content=draftContent.trim(); if(!content){ setMsg('content required'); return }
687
276
  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)`,
277
+ try{
278
+ const res:any=await rpc('todo.mutate',{ action:'add', target:draftTarget, content, due:draftDue||undefined, quadrant:draftQuadrant||undefined, cat:draftCat||undefined })
279
+ if(res?.ok){ setDraftContent(''); setDraftDue(''); setDraftQuadrant(''); setDraftCat(''); setMsg(`added (id: ${res.id})`); await load() }
280
+ else setMsg(`add failed: ${res?.message??res?.error??'unknown'}`)
281
+ } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
282
+ },[rpc,draftContent,draftTarget,draftDue,draftQuadrant,draftCat,load])
283
+
284
+ const doneTodo=React.useCallback(async(item:any)=>{
285
+ 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)}`) }
286
+ },[rpc,load])
287
+ const undoTodo=React.useCallback(async(item:any)=>{
288
+ 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)}`) }
289
+ },[rpc,load])
290
+ const removeTodo=React.useCallback(async(item:any)=>{
291
+ 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)}`) }
292
+ },[rpc,load])
293
+ const startEdit=(item:any)=>{ setEditId(item.id); setEditContent(item.text??''); setEditDue(item.due??''); setEditQuadrant(item.quadrant??''); setEditStatus(item.status??'pending'); setEditCat(item.cat??'') }
294
+ const saveEdit=React.useCallback(async(item:any)=>{
295
+ try{
296
+ const patch:any={}
297
+ if(editContent!==item.text) patch.content=editContent
298
+ if(editQuadrant!==(item.quadrant??'')) patch.quadrant=editQuadrant||null
299
+ if(editDue!==(item.due??'')) patch.due=editDue||null
300
+ if(editStatus!==item.status) patch.status=editStatus
301
+ if(editCat!==(item.cat??'')) patch.cat=editCat||null
302
+ const res:any=await rpc('todo.mutate',{ action:'update', target:item.target, id:item.id, ...patch })
303
+ if(res?.ok){ setEditId(null); setMsg(res.message??'updated'); await load() } else setMsg(`update failed: ${res?.message??res?.error}`)
304
+ } catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) }
305
+ },[rpc,editContent,editDue,editQuadrant,editStatus,editCat,load])
306
+
307
+ return React.createElement('div',{className:'memx-panel'},
308
+ React.createElement('div',{className:'memx-toolbar'},
309
+ 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))),
310
+ 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'})),
311
+ ),
312
+ React.createElement('div',{className:'memx-toolbar'},
313
+ 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'),
314
+ 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'),
315
+ 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'),
316
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'data-testid':'todo-refresh'}, React.createElement(Ico,{d:ICO.refresh}), 'Refresh'),
317
+ ),
318
+ React.createElement('div',{className:'memx-form'},
319
+ React.createElement('button',{onClick:()=>setOpenAdd(v=>!v), className:'memx-btn memx-btn-ghost', style:{justifyContent:'space-between'}, 'aria-expanded':openAdd},
320
+ React.createElement('span',{style:{display:'flex',gap:8,alignItems:'center'}}, React.createElement(Ico,{d:ICO.plus}), 'Add todo'),
321
+ React.createElement('span',{className:'memx-muted'}, openAdd?'Hide':'Show'),
322
+ ),
323
+ openAdd ? React.createElement(React.Fragment,null,
324
+ 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'}),
325
+ React.createElement('div',{className:'memx-formrow'},
326
+ 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))),
327
+ React.createElement('select',{value:draftQuadrant, onChange:(e:any)=>setDraftQuadrant(e.target.value), className:'memx-select', 'aria-label':'Quadrant', 'data-testid':'todo-add-quadrant'},
328
+ 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')),
329
+ 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'}),
330
+ 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'}),
331
+ ),
332
+ React.createElement('button',{onClick:addTodo, className:'memx-btn memx-btn-primary', 'data-testid':'todo-add-btn'}, 'Add'),
333
+ ) : null,
716
334
  ),
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,
335
+ msg ? React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}}, msg) : null,
336
+ 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.') :
337
+ React.createElement('div',null,
338
+ React.createElement('div',{className:'memx-muted', style:{marginBottom:6}}, `${filtered.length} todos${!showAll && filtered.length===8?' (smart view ≤8)':''}`),
339
+ React.createElement('div',{className:'memx-grid'},
340
+ ...filtered.map((it:any)=> React.createElement('div',{key:it.id, className:'memx-card'},
341
+ React.createElement('div',{style:{display:'flex',gap:6,flexWrap:'wrap'}},
342
+ React.createElement('span',{className:'memx-chip'}, it.target),
343
+ it.quadrant?React.createElement('span',{className:'memx-chip'}, it.quadrant):null,
344
+ it.due?React.createElement('span',{className:'memx-chip'}, `due ${it.due}`):null,
345
+ it.status!=='pending'?React.createElement('span',{className:'memx-chip'}, it.status):null,
346
+ it.cat?React.createElement('span',{className:'memx-chip'}, it.cat):null,
738
347
  ),
739
- ),
348
+ editId===it.id ? React.createElement('div',null,
349
+ 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}`}),
350
+ React.createElement('div',{className:'memx-formrow', style:{marginTop:8}},
351
+ 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')),
352
+ 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}`}),
353
+ 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))),
354
+ 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}`}),
355
+ ),
356
+ React.createElement('div',{className:'memx-actions', style:{marginTop:8}},
357
+ React.createElement('button',{onClick:()=>saveEdit(it), className:'memx-btn memx-btn-primary', 'data-testid':`todo-save-${it.id}`}, 'Save'),
358
+ React.createElement('button',{onClick:()=>setEditId(null), className:'memx-btn', 'data-testid':`todo-cancel-${it.id}`}, 'Cancel'),
359
+ ),
360
+ ) : React.createElement(React.Fragment,null,
361
+ React.createElement('div',{className:'memx-pre'}, it.text),
362
+ React.createElement('div',{className:'memx-actions', style:{marginTop:8}},
363
+ 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'),
364
+ React.createElement('button',{onClick:()=>startEdit(it), className:'memx-btn', 'data-testid':`todo-edit-${it.id}`}, 'Edit'),
365
+ React.createElement('button',{onClick:()=>removeTodo(it), className:'memx-btn', 'data-testid':`todo-remove-${it.id}`}, 'Remove'),
366
+ ),
367
+ ),
368
+ it.doneAt||it.past?React.createElement('div',{className:'memx-xs'}, [it.doneAt?`done:${it.doneAt}`:'', it.past?`past ${it.day}`:''].filter(Boolean).join(' · ')):null,
369
+ ))
740
370
  ),
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.'),
371
+ ),
744
372
  )
745
373
  }
746
374
 
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'),
375
+ // ── Skills ─────────────────────────────────────────────────────────
376
+ function SkillsView({ ctx }: {ctx:any}): React.ReactElement {
377
+ const rpc=useRpc(ctx)
378
+ const [entries,setEntries]=React.useState<any[]>([])
379
+ const [loading,setLoading]=React.useState(true)
380
+ const [msg,setMsg]=React.useState('')
381
+ const [q,setQ]=React.useState('')
382
+ const load=React.useCallback(async()=>{
383
+ setLoading(true); setMsg('')
384
+ 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'}`) }
385
+ catch(e:any){ setMsg(`error: ${e?.message??String(e)}`) } finally{ setLoading(false) }
386
+ },[rpc])
387
+ React.useEffect(()=>{ load() },[load])
388
+ const filtered=React.useMemo(()=>{
389
+ const t=q.trim().toLowerCase(); if(!t) return entries
390
+ return entries.filter((e:any)=> `${e.name} ${e.description} ${e.origin}`.toLowerCase().includes(t))
391
+ },[entries,q])
392
+ if(loading) return React.createElement('div',{className:'memx-muted'},'Loading skills…')
393
+ return React.createElement('div',{className:'memx-panel'},
394
+ React.createElement('div',{className:'memx-toolbar'},
395
+ 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'})),
396
+ React.createElement('button',{onClick:load, className:'memx-btn memx-btn-ghost', 'data-testid':'skills-refresh'}, React.createElement(Ico,{d:ICO.refresh}), 'Refresh'),
812
397
  ),
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
- ),
398
+ React.createElement('div',{className:'memx-muted'}, `${filtered.length}/${entries.length} skills — read-only, no mutation`),
399
+ filtered.length===0 ? React.createElement('div',{className:'memx-empty'}, msg||'No skills') :
400
+ React.createElement('div',{className:'memx-grid memx-grid-2'},
401
+ ...filtered.map((e:any)=> React.createElement('div',{key:e.name, className:'memx-card'},
402
+ React.createElement('div',{className:'memx-cardtitle', style:{display:'flex',gap:8,alignItems:'center'}}, React.createElement(Ico,{d:ICO.layers, size:14}), e.name),
403
+ React.createElement('div',{className:'memx-sm'}, `[${e.origin}] ${e.path}`),
404
+ React.createElement('div',{className:'memx-pre-sm'}, e.description),
405
+ 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,
406
+ ))
407
+ ),
408
+ msg?React.createElement('div',{className:'memx-muted', style:{marginTop:8}},msg):null,
830
409
  )
831
410
  }
832
411
 
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 () => {
412
+ // ── Memory List ────────────────────────────────────────────────────
413
+ function MemoryListView({ ctx }: {ctx:any}): React.ReactElement {
414
+ const rpc=useRpc(ctx)
415
+ const [track,setTrack]=React.useState<string>('key')
416
+ const [cwd,setCwd]=React.useState<string>(()=>sessionCwd(ctx))
417
+ const [entries,setEntries]=React.useState<string[]>([])
418
+ const [loading,setLoading]=React.useState(true)
419
+ const [msg,setMsg]=React.useState('')
420
+ const [q,setQ]=React.useState('')
421
+ const needsCwd=track==='key'||track==='project'
422
+ const load=React.useCallback(async()=>{
838
423
  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)'),
424
+ try{
425
+ if(needsCwd && !cwd.trim()){ setEntries([]); setMsg('cwd required for key/project'); setLoading(false); return }
426
+ const res:any=await rpc('memory.list',{ target:track, cwd: needsCwd?cwd.trim():undefined })
427
+ setEntries(Array.isArray(res?.entries)?res.entries:[])
428
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
429
+ },[rpc,track,cwd,needsCwd])
430
+ React.useEffect(()=>{ load() },[load])
431
+
432
+ const filtered=React.useMemo(()=>{
433
+ const t=q.trim().toLowerCase(); if(!t) return entries
434
+ return entries.filter(e=> e.toLowerCase().includes(t))
435
+ },[entries,q])
436
+
437
+ return React.createElement('div',{className:'memx-panel'},
438
+ 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))),
439
+ React.createElement('div',{className:'memx-toolbar'},
440
+ React.createElement('div',{className:'memx-field'},
441
+ 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'}),
442
+ React.createElement('button',{onClick:load, className:'memx-btn', 'data-testid':'mem-refresh'}, React.createElement(Ico,{d:ICO.refresh, size:14}), 'Refresh'),
876
443
  ),
444
+ 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
445
  ),
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)),
446
+ msg?React.createElement('div',{className:'memx-muted'},msg):null,
447
+ loading ? React.createElement('div',{className:'memx-muted'},'Loading memory…') : filtered.length===0 ? React.createElement('div',{className:'memx-empty'}, '(no entries)') :
448
+ React.createElement('div',null,
449
+ React.createElement('div',{className:'memx-muted', style:{marginBottom:6}}, `${filtered.length}/${entries.length} entries`),
450
+ React.createElement('div',{className:'memx-grid'},
451
+ ...filtered.map((e:string, idx:number)=> React.createElement('div',{key:idx, className:'memx-card'}, React.createElement('div',{className:'memx-pre'}, e)) ),
888
452
  ),
889
453
  ),
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
454
  )
913
455
  }
914
456
 
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
- ),
457
+ // ── Health ─────────────────────────────────────────────────────────
458
+ function HealthView({ ctx }: {ctx:any}): React.ReactElement {
459
+ const [health,setHealth]=React.useState<any>(null)
460
+ const [loading,setLoading]=React.useState(true)
461
+ const [msg,setMsg]=React.useState('')
462
+ const load=React.useCallback(async()=>{
463
+ setLoading(true); setMsg('')
464
+ try{
465
+ const conn=(ctx as any).connection ?? (ctx as any).get?.('connection')
466
+ if(!conn?.rpc?.call) throw new Error('RPC not available')
467
+ const cwd=(ctx as any)?.sessions?.list?.getSnapshot?.()?.byId?.[(ctx as any)?.sessions?.list?.getSnapshot?.()?.current]?.cwd || ''
468
+ const res:any=await conn.rpc.call('/dsh-maestro-memory-health','get',{cwd})
469
+ const val=res?.ok===true?res.value:res
470
+ if(val && typeof val.project!=='undefined') setHealth(val); else setMsg(`unexpected: ${JSON.stringify(val).slice(0,120)}`)
471
+ } catch(e:any){ setMsg(`load failed: ${e?.message??String(e)}`) } finally{ setLoading(false) }
472
+ },[ctx])
473
+ React.useEffect(()=>{ load() },[load])
474
+ if(loading) return React.createElement('div',{className:'memx-muted'},'Loading health…')
475
+ 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'))
476
+ if(!health) return React.createElement('div',{className:'memx-muted'},'No data')
477
+ const cov=health.project.coverage as number
478
+ const covColor = cov>=90 ? 'var(--dsw-alias-state-success-primary)' : cov>=50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)'
479
+ return React.createElement('div',{className:'memx-panel'},
480
+ React.createElement('div',{className:'memx-grid memx-grid-4'},
481
+ 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`)),
482
+ 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%)')),
483
+ 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')),
484
+ 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+')),
485
+ ),
486
+ health.fiveDim?React.createElement('div',{className:'memx-card'},
487
+ 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)`),
488
+ React.createElement('div',{className:'memx-grid memx-grid-4'},
489
+ (['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])))),
490
+ 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
491
  ),
492
+ ):null,
493
+ React.createElement('div',{className:'memx-muted'}, `Longest ${health.longest.length} entries`),
494
+ React.createElement('div',{className:'memx-grid'},
495
+ ...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}},
496
+ 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)),
497
+ React.createElement('button',{onClick: async()=>{
498
+ try{
499
+ const conn2=(ctx as any).connection ?? (ctx as any).get?.('connection'); if(!conn2?.rpc?.call) throw new Error('RPC not available')
500
+ 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)}`)
501
+ } catch(e:any){ setMsg(`propose failed: ${e?.message??String(e)}`) }
502
+ }, className:'memx-btn memx-btn-primary', 'data-testid':`health-propose-${i}`, style:{whiteSpace:'nowrap', flex:'none'}}, 'Suggest as KEY'),
503
+ )),
957
504
  ),
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 }),
505
+ msg?React.createElement('div',{className:'memx-muted', style:{whiteSpace:'pre-wrap'}},msg):null,
506
+ 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
507
  )
968
508
  }
969
509
 
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',
510
+ // ── Root ───────────────────────────────────────────────────────────
511
+ function MemoryView({ ctx }: {ctx:any}): React.ReactElement {
512
+ const rpc=useRpc(ctx)
513
+ const [tab,setTab]=React.useState<'memory'|'review'|'todos'|'skills'|'health'>('memory')
514
+ const [pending,setPending]=React.useState(0)
515
+ const refreshPending=React.useCallback(async()=>{
516
+ try{ const res:any=await rpc('status',{}); setPending(typeof res?.queue==='number'?res.queue:0) } catch{ setPending(0) }
517
+ },[rpc])
518
+ React.useEffect(()=>{ refreshPending() },[refreshPending,tab])
519
+
520
+ const tabs: Array<{id:typeof tab; label:string; icon:string; count?:number}> = [
521
+ {id:'memory', label:'Memory', icon:ICO.mem},
522
+ {id:'review', label:'Queue', icon:ICO.inbox, count:pending},
523
+ {id:'todos', label:'Todos', icon:ICO.check},
524
+ {id:'skills', label:'Skills', icon:ICO.layers},
525
+ {id:'health', label:'Health', icon:ICO.activity},
526
+ ]
527
+
528
+ return React.createElement('div',{className:'dshmem memx', style:{padding:'12px 12px 16px'}},
529
+ React.createElement('style',null, MEM_CSS),
530
+ React.createElement('div',{className:'memx-header'},
531
+ React.createElement('div',{className:'memx-header-main'},
532
+ React.createElement('div',{className:'memx-title'}, React.createElement(Ico,{d:ICO.spark, size:18}), 'Memory'),
533
+ React.createElement('div',{className:'memx-subtitle'}, 'Durable layered memory — five tracks, gated writes, todos & skills'),
534
+ ),
535
+ React.createElement('div',{className:'memx-header-actions'},
536
+ React.createElement('button',{onClick:refreshPending, className:'memx-iconbtn', 'aria-label':'Refresh status', title:'Refresh'}, React.createElement(Ico,{d:ICO.refresh, size:16})),
537
+ ),
538
+ ),
539
+ React.createElement('div',{className:'memx-layout'},
540
+ React.createElement('div',{className:'memx-nav', role:'tablist', 'aria-label':'Memory sections'},
541
+ ...tabs.map(t=> React.createElement('button',{
542
+ 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
543
  },
980
- () => React.createElement(MemoryView, { ctx }),
544
+ React.createElement(Ico,{d:t.icon}), t.label,
545
+ t.count!==undefined && t.count>0 ? React.createElement('span',{className:'memx-badge', 'data-testid':'tab-badge-review'}, String(t.count)) : null,
546
+ t.id==='memory'?React.createElement('span',{className:'memx-navcount'}, '5 tracks'):null,
547
+ )),
981
548
  ),
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'
549
+ React.createElement('div',{className:'memx-panel', role:'tabpanel', id:`panel-${tab}`, 'aria-labelledby':`tab-${tab}`},
550
+ tab==='memory'?React.createElement(MemoryListView,{ctx}):
551
+ tab==='review'?React.createElement(ReviewQueueView,{ctx, onPendingChange:setPending}):
552
+ tab==='todos'?React.createElement(TodosView,{ctx}):
553
+ tab==='health'?React.createElement(HealthView,{ctx}):
554
+ React.createElement(SkillsView,{ctx}),
555
+ ),
556
+ ),
557
+ )
558
+ }
998
559
 
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
- }
560
+ export function apply(ctx:any): void {
561
+ ctx.effect(()=>{
562
+ const dispose=ctx.slots.inject('conversation.view', ()=> ctx.slots.register({ name:'conversation.view', id:'maestro-memory', order:40, label:()=>'Memory' }, ()=> React.createElement(MemoryView,{ctx})))
563
+ return ()=>{ if(typeof dispose==='function') dispose() }
564
+ },'maestro-memory: view')
565
+
566
+ ctx.effect(()=>{
567
+ const conn=(ctx as any).connection ?? (ctx as any).get?.('connection')
568
+ if(!conn?.rpc?.call) return ()=>{}
569
+ const BADGE_ATTR='data-dshmem-header-badge'
570
+ const VIEW_ATTR='data-dshmem-memory-tab'
571
+ const findMemoryTab=(): HTMLElement|null=>{
572
+ const tabs=document.querySelectorAll('button[role="tab"]')
573
+ for(const tab of Array.from(tabs)){ if(tab.getAttribute(VIEW_ATTR)==='1') return tab as HTMLElement }
574
+ 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
575
  return null
1012
576
  }
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)
577
+ const renderBadge=(count:number): void=>{
578
+ const tab=findMemoryTab(); if(!tab) return
579
+ const existing=tab.querySelector(`[${BADGE_ATTR}]`)
580
+ if(count<=0){ if(existing) existing.remove(); return }
581
+ if(existing && (existing.textContent===String(count))) return
582
+ if(existing) existing.remove()
583
+ const badge=document.createElement('span'); badge.setAttribute(BADGE_ATTR,''); badge.className='dshmem-header-badge'; badge.textContent=String(count); tab.appendChild(badge)
1029
584
  }
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
- }
585
+ let styleEl=document.getElementById('dshmem-header-style') as HTMLStyleElement|null
586
+ if(!styleEl){ styleEl=document.createElement('style'); styleEl.id='dshmem-header-style'; styleEl.textContent=HEADER_BADGE_CSS; document.head.appendChild(styleEl) }
587
+ let disposed=false; let lastCount=0
588
+ const refresh=async():Promise<void>=>{
589
+ if(disposed) return
590
+ 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
591
  }
1053
-
1054
592
  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)
593
+ const timer=window.setInterval(()=>{ void refresh() },8000)
594
+ const observer=new MutationObserver(()=>{
595
+ if(disposed) return; const tab=findMemoryTab(); if(!tab) return
596
+ const hasBadge=tab.querySelector(`[${BADGE_ATTR}]`)
597
+ if(lastCount>0 && !hasBadge) renderBadge(lastCount); else if(lastCount<=0 && hasBadge) renderBadge(0)
1066
598
  })
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')
599
+ observer.observe(document.body,{childList:true,subtree:true})
600
+ return ()=>{ disposed=true; window.clearInterval(timer); observer.disconnect(); styleEl?.remove(); const tab=findMemoryTab(); if(tab) tab.querySelector(`[${BADGE_ATTR}]`)?.remove() }
601
+ },'maestro-memory: header-badge')
1078
602
  }