@saluzi/saluzi-edu 0.2.61 → 0.2.62

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.
@@ -14,14 +14,23 @@
14
14
  cursor, so the menu can extend beyond the parade window.
15
15
 
16
16
  Contract with the electron main script:
17
- - Window size: MENU_W x MENU_H design px (158x147) — the constants in
18
- paradeWindow.ts MUST match the locked layout below.
19
- - Query params: ?mode=<displayMode>&rot=<dynamic|static>&zoom=<dpiScale>
20
- zoom is applied identically to index.html (physical px compensation on
17
+ - Window: one PRE-WARMED frameless transparent popup, created hidden at
18
+ startup and REUSED for every right-click (hide/show, never recreated —
19
+ recreating a renderer process per open read as a laggy menu). Main
20
+ re-anchors it via setBounds at the OS cursor and sizes it
21
+ MENU_W x MENU_H design px x unitScale — the constants in paradeWindow.ts
22
+ MUST match the locked layout below.
23
+ - State: pushed per open over IPC parade-menu-state {mode, rot}; the page
24
+ applies it and acks via paradeMenu.ready(), then main shows the window
25
+ (no stale highlight). The ?mode/&rot/&zoom=<dpiScale> query params are
26
+ only the initial-load fallback / browser-debugging path. zoom is applied
27
+ identically to index.html (physical px compensation on
21
28
  --force-device-scale-factor=1 Win11; multiply window size by unitScale).
22
29
  - Selections: window.paradeMenu.action({type:'view', value}) or
23
30
  ({type:'rotation'}) → IPC parade-menu-action → relayed to the parade
24
31
  window renderer, which stays the single source of truth for mode state.
32
+ Escape dismisses via window.paradeMenu.dismiss() (hide, not close —
33
+ the window is reused).
25
34
  -->
26
35
  <style>
27
36
  :root {
@@ -124,35 +133,67 @@
124
133
  <div class="ctx-item" id="ctx-mode">Rotation: Dynamic</div>
125
134
  </div>
126
135
  <script>
127
- // Reflect the parade window's current state passed via query string.
128
- ;(function () {
129
- var q = new URLSearchParams(window.location.search)
130
-
131
- // DPI compensation — identical mechanism to index.html/cube.js: the main
132
- // process forces 1 CSS px = 1 physical px on Win11, then we CSS-zoom the
133
- // content by the OS DPI factor so physical size matches intent. Applied at
134
- // parse time; the window was sized as design px x unitScale by main.
135
- var zoom = parseFloat(q.get('zoom')) || 0
136
- if (zoom > 1) document.documentElement.style.zoom = String(zoom)
137
-
138
- // Mark the active display mode radio item.
139
- var mode = q.get('mode') || ''
136
+ // ---- State application ----
137
+ // The electron main process pre-warms this page and reuses its window for
138
+ // every right-click: each open pushes the parade window's current state via
139
+ // the parade-menu-state IPC; the query params below are only the initial
140
+ // fallback / browser-debugging path. applyState() must therefore REMOVE the
141
+ // previous open's active marks, not just add new ones.
142
+ function applyState(state) {
143
+ state = (state && typeof state === 'object') ? state : {}
144
+ var mode = String(state.mode || '')
140
145
  var views = document.querySelectorAll('.ctx-view')
141
146
  for (let i = 0; i < views.length; i++) {
142
147
  if (views[i].getAttribute('data-view') === mode) {
143
148
  views[i].classList.add('active')
149
+ } else {
150
+ views[i].classList.remove('active')
144
151
  }
145
152
  }
146
-
147
- // Rotation label.
148
- var rot = q.get('rot') === 'static' ? 'static' : 'dynamic'
153
+ var rot = state.rot === 'static' ? 'static' : 'dynamic'
149
154
  var modeEl = document.getElementById('ctx-mode')
150
155
  if (modeEl) {
151
156
  modeEl.textContent = 'Rotation: ' + (rot === 'dynamic' ? 'Dynamic' : 'Static')
152
157
  }
158
+ }
159
+
160
+ // The page itself is reused across opens, so the load-time #menu animation
161
+ // would only ever play once — replay it on every state push (i.e. every open).
162
+ function replayOpenAnimation() {
163
+ var menuEl = document.getElementById('menu')
164
+ if (!menuEl) return
165
+ menuEl.style.animation = 'none'
166
+ void menuEl.offsetWidth // force reflow so the animation restarts
167
+ menuEl.style.animation = ''
168
+ }
169
+
170
+ // Initial state via query string (fallback / browser debugging).
171
+ ;(function () {
172
+ var q = new URLSearchParams(window.location.search)
173
+
174
+ // DPI compensation — identical mechanism to index.html/cube.js: the main
175
+ // process forces 1 CSS px = 1 physical px on Win11, then we CSS-zoom the
176
+ // content by the OS DPI factor so physical size matches intent. Applied at
177
+ // parse time; the window was sized as design px x unitScale by main.
178
+ var zoom = parseFloat(q.get('zoom')) || 0
179
+ if (zoom > 1) document.documentElement.style.zoom = String(zoom)
180
+
181
+ applyState({ mode: q.get('mode') || '', rot: q.get('rot') || '' })
153
182
  })()
154
183
 
155
- // Report a selection to the electron main process, which closes the popup and
184
+ // Per-open state push: apply, replay the open animation, then ack so main
185
+ // shows the window with the highlight already up to date.
186
+ if (window.paradeMenu && typeof window.paradeMenu.onState === 'function') {
187
+ window.paradeMenu.onState(function (state) {
188
+ applyState(state)
189
+ replayOpenAnimation()
190
+ if (typeof window.paradeMenu.ready === 'function') {
191
+ window.paradeMenu.ready()
192
+ }
193
+ })
194
+ }
195
+
196
+ // Report a selection to the electron main process, which hides the popup and
156
197
  // relays the action to the parade window renderer.
157
198
  function reportAction(action) {
158
199
  if (window.paradeMenu && typeof window.paradeMenu.action === 'function') {
@@ -178,10 +219,17 @@ if (modeEl) {
178
219
  })
179
220
  }
180
221
 
181
- // Escape dismisses (native-menu parity).
222
+ // Escape dismisses (native-menu parity). The popup window is reused, so the
223
+ // page must NOT window.close() itself (that would destroy the prewarmed
224
+ // renderer) — it asks main to hide it instead. window.close() stays as the
225
+ // browser-debugging fallback where the bridge is missing.
182
226
  document.addEventListener('keydown', function (e) {
183
227
  if (e.key === 'Escape') {
184
- try { window.close() } catch (_) { /* ignore */ }
228
+ if (window.paradeMenu && typeof window.paradeMenu.dismiss === 'function') {
229
+ window.paradeMenu.dismiss()
230
+ } else {
231
+ try { window.close() } catch (_) { /* ignore */ }
232
+ }
185
233
  }
186
234
  })
187
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saluzi/saluzi-edu",
3
- "version": "0.2.61",
3
+ "version": "0.2.62",
4
4
  "description": "Saluzi CLI - interactive AI coding assistant in the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Regression: model selector gray after re-entering a session.
3
+ *
4
+ * models_state is a one-shot snapshot — the worker sends it only once per
5
+ * bridge connect, and the web UI restores it from the last models_state
6
+ * event in GET /history. Anything that wipes or evicts the event log
7
+ * (/clear, /new, window eviction) used to make the selector permanently
8
+ * gray until the worker reconnected. The bus now keeps the latest snapshot
9
+ * out-of-band: /clear re-seeds it, /history merges it when the log lacks
10
+ * the event. Covers both worker write paths:
11
+ * - v1 session-ingress (HybridTransport POST → ingestBridgeMessage)
12
+ * - v2 CCR worker events (POST /v1/code/sessions/:id/worker/events)
13
+ */
14
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
15
+ import { mockConfigModule } from './helpers/mock-config'
16
+
17
+ mock.module('../config', () => mockConfigModule({ pollTimeout: 1 }))
18
+
19
+ import { Hono } from 'hono'
20
+ import { storeReset } from '../store'
21
+ import {
22
+ getAllEventBuses,
23
+ removeEventBus,
24
+ getEventBus,
25
+ } from '../transport/event-bus'
26
+
27
+ import v1SessionIngress from '../routes/v1/session-ingress'
28
+ import v2WorkerEvents from '../routes/v2/worker-events'
29
+ import webSessions from '../routes/web/sessions'
30
+ import { issueToken } from '../auth/token'
31
+ import { deletePersistedEvents } from '../services/event-persistence'
32
+ import { publishSessionEvent } from '../services/transport'
33
+
34
+ function createApp() {
35
+ const app = new Hono()
36
+ app.route('/v2/session_ingress', v1SessionIngress)
37
+ app.route('/v1/code/sessions', v2WorkerEvents)
38
+ app.route('/web', webSessions)
39
+ return app
40
+ }
41
+
42
+ const AUTH_HEADERS = {
43
+ Authorization: 'Bearer test-api-key',
44
+ 'X-Username': 'testuser',
45
+ }
46
+
47
+ function auth(user: string): string {
48
+ return `Bearer ${issueToken(user).token}`
49
+ }
50
+
51
+ const MODELS_STATE_MSG = {
52
+ type: 'models_state',
53
+ models_state: {
54
+ availableModels: [
55
+ { modelId: 'firstParty', name: 'First Party' },
56
+ { modelId: 'firstParty:opus', name: 'Opus' },
57
+ ],
58
+ currentModelId: 'firstParty',
59
+ },
60
+ }
61
+
62
+ describe('models_state snapshot survives history loss (gray selector regression)', () => {
63
+ let app: Hono
64
+ let sessionId: string
65
+
66
+ beforeEach(async () => {
67
+ storeReset()
68
+ for (const [key] of getAllEventBuses()) {
69
+ removeEventBus(key)
70
+ }
71
+ app = createApp()
72
+
73
+ const res = await app.request('/web/sessions', {
74
+ method: 'POST',
75
+ headers: {
76
+ Authorization: auth('user-1'),
77
+ 'Content-Type': 'application/json',
78
+ },
79
+ body: JSON.stringify({}),
80
+ })
81
+ const body = (await res.json()) as { id: string }
82
+ sessionId = body.id
83
+ })
84
+
85
+ async function fetchHistory(): Promise<
86
+ Array<{ id: string; type: string; payload: Record<string, unknown> }>
87
+ > {
88
+ const res = await app.request(`/web/sessions/${sessionId}/history`, {
89
+ headers: { Authorization: auth('user-1') },
90
+ })
91
+ expect(res.status).toBe(200)
92
+ const body = (await res.json()) as {
93
+ events: Array<Record<string, unknown>>
94
+ }
95
+ return body.events as Array<{
96
+ id: string
97
+ type: string
98
+ payload: Record<string, unknown>
99
+ }>
100
+ }
101
+
102
+ function parseModelsState(
103
+ events: Array<{
104
+ id: string
105
+ type: string
106
+ payload: Record<string, unknown>
107
+ }>,
108
+ ): { availableModels: unknown[]; currentModelId: string } | null {
109
+ // Same parse the web adapter's loadHistory performs.
110
+ for (const event of [...events].reverse()) {
111
+ if (event.type !== 'models_state') continue
112
+ const raw = event.payload.raw
113
+ const state = (
114
+ raw && typeof raw === 'object'
115
+ ? (raw as Record<string, unknown>).models_state
116
+ : event.payload.models_state
117
+ ) as { availableModels: unknown[]; currentModelId: string } | undefined
118
+ if (
119
+ state &&
120
+ Array.isArray(state.availableModels) &&
121
+ typeof state.currentModelId === 'string'
122
+ ) {
123
+ return state
124
+ }
125
+ }
126
+ return null
127
+ }
128
+
129
+ test('v1 session-ingress POST path reaches /history', async () => {
130
+ const ingestRes = await app.request(
131
+ `/v2/session_ingress/session/${sessionId}/events`,
132
+ {
133
+ method: 'POST',
134
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
136
+ },
137
+ )
138
+ expect(ingestRes.status).toBe(200)
139
+
140
+ const state = parseModelsState(await fetchHistory())
141
+ expect(state).not.toBeNull()
142
+ expect(state?.currentModelId).toBe('firstParty')
143
+ })
144
+
145
+ test('v2 worker events POST path (payload envelope) reaches /history', async () => {
146
+ const ingestRes = await app.request(
147
+ `/v1/code/sessions/${sessionId}/worker/events`,
148
+ {
149
+ method: 'POST',
150
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
151
+ body: JSON.stringify([
152
+ { payload: { ...MODELS_STATE_MSG, uuid: 'u1' } },
153
+ ]),
154
+ },
155
+ )
156
+ expect(ingestRes.status).toBe(200)
157
+
158
+ const state = parseModelsState(await fetchHistory())
159
+ expect(state).not.toBeNull()
160
+ expect(state?.availableModels).toHaveLength(2)
161
+ })
162
+
163
+ test('POST /clear re-seeds models_state so re-entry still restores the selector', async () => {
164
+ await app.request(`/v2/session_ingress/session/${sessionId}/events`, {
165
+ method: 'POST',
166
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
168
+ })
169
+
170
+ const clearRes = await app.request(`/web/sessions/${sessionId}/clear`, {
171
+ method: 'POST',
172
+ headers: { Authorization: auth('user-1') },
173
+ })
174
+ expect(clearRes.status).toBe(200)
175
+
176
+ const state = parseModelsState(await fetchHistory())
177
+ expect(state).not.toBeNull()
178
+ expect(state?.currentModelId).toBe('firstParty')
179
+ })
180
+
181
+ test('/history merges the bus snapshot when the event log lost the entry', async () => {
182
+ await app.request(`/v2/session_ingress/session/${sessionId}/events`, {
183
+ method: 'POST',
184
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({ events: [MODELS_STATE_MSG] }),
186
+ })
187
+
188
+ // Simulate pure log loss (server older than the re-seed, eviction, …):
189
+ // wipe events but keep the bus alive — the snapshot must cover it.
190
+ getEventBus(sessionId).clearHistory()
191
+ deletePersistedEvents(sessionId)
192
+
193
+ const state = parseModelsState(await fetchHistory())
194
+ expect(state).not.toBeNull()
195
+ expect(state?.availableModels).toHaveLength(2)
196
+ })
197
+
198
+ test('snapshot updates to the latest models_state (set_model re-report)', async () => {
199
+ publishSessionEvent(sessionId, 'models_state', MODELS_STATE_MSG, 'inbound')
200
+ publishSessionEvent(
201
+ sessionId,
202
+ 'models_state',
203
+ {
204
+ type: 'models_state',
205
+ models_state: {
206
+ ...MODELS_STATE_MSG.models_state,
207
+ currentModelId: 'firstParty:opus',
208
+ },
209
+ },
210
+ 'inbound',
211
+ )
212
+
213
+ const state = parseModelsState(await fetchHistory())
214
+ expect(state?.currentModelId).toBe('firstParty:opus')
215
+ })
216
+ })
@@ -34,6 +34,7 @@ import {
34
34
  import { createWorkItem, stopWork } from '../../services/work-dispatch'
35
35
  import { createSSEStream } from '../../transport/sse-writer'
36
36
  import { getEventBus } from '../../transport/event-bus'
37
+ import { publishSessionEvent } from '../../services/transport'
37
38
 
38
39
  const app = new Hono()
39
40
 
@@ -286,6 +287,26 @@ app.get('/sessions/:id/history', sessionAuth, async c => {
286
287
  const events = [...byId.values()].sort(
287
288
  (a, b) => a.createdAt - b.createdAt || a.seqNum - b.seqNum,
288
289
  )
290
+ // models_state fallback: the worker reports the model list once per bridge
291
+ // connect, so the event log can legitimately lack it (/clear before the
292
+ // re-seed existed, event-window eviction, pre-persistence servers). The
293
+ // bus keeps the latest snapshot out-of-band — merge it here so the web UI
294
+ // can restore the model selector on every re-entry.
295
+ if (!events.some(e => e.type === 'models_state')) {
296
+ const snapshot = getEventBus(sessionId).modelsStateSnapshot
297
+ if (snapshot) {
298
+ const lastCreated = events[events.length - 1]?.createdAt ?? Date.now()
299
+ events.push({
300
+ id: `models_state_snapshot_${sessionId}`,
301
+ sessionId,
302
+ seqNum: 0,
303
+ type: 'models_state',
304
+ payload: { models_state: snapshot },
305
+ direction: 'inbound',
306
+ createdAt: lastCreated + 1,
307
+ })
308
+ }
309
+ }
289
310
  return c.json({ events }, 200)
290
311
  })
291
312
 
@@ -344,6 +365,18 @@ app.post('/sessions/:id/clear', sessionAuth, async c => {
344
365
  },
345
366
  { transient: true },
346
367
  )
368
+ // Re-seed models_state from the bus snapshot. The worker only reports
369
+ // models_state once per bridge connect, so a plain history wipe would make
370
+ // the model selector permanently gray after re-entering the session (and
371
+ // /model would report "未提供模型选择能力") until the worker reconnects.
372
+ if (bus.modelsStateSnapshot) {
373
+ publishSessionEvent(
374
+ sessionId,
375
+ 'models_state',
376
+ { type: 'models_state', models_state: bus.modelsStateSnapshot },
377
+ 'inbound',
378
+ )
379
+ }
347
380
  log(`[RCS] Cleared conversation history for session ${sessionId}`)
348
381
  return c.json({ status: 'ok' }, 200)
349
382
  })
@@ -169,6 +169,18 @@ export function publishSessionEvent(
169
169
  const bus = getEventBus(sessionId)
170
170
  const eventId = randomUUID()
171
171
 
172
+ // models_state is a one-shot snapshot from the worker (sent on bridge
173
+ // connect, never re-sent during the worker's lifetime). Keep the latest
174
+ // payload on the bus so /clear re-seeding and /history fallback can
175
+ // restore the web UI's model selector even after the event log lost the
176
+ // original entry.
177
+ if (type === 'models_state' && payload && typeof payload === 'object') {
178
+ const state = (payload as Record<string, unknown>).models_state
179
+ if (state && typeof state === 'object') {
180
+ bus.modelsStateSnapshot = state as Record<string, unknown>
181
+ }
182
+ }
183
+
172
184
  const normalized = normalizePayload(type, payload)
173
185
  const transient = TRANSIENT_EVENT_TYPES.has(type)
174
186
 
@@ -30,6 +30,16 @@ export class EventBus {
30
30
  MAX_SEEN_UUIDS_PER_BUS,
31
31
  )
32
32
  private seenUuidWriteIdx = 0
33
+ /**
34
+ * Latest models_state payload for the session, kept OUT of the retained
35
+ * event window. The worker reports models_state only once per bridge
36
+ * connect, while the event log is mutable (/clear wipes it, the bounded
37
+ * window evicts old entries) — without this snapshot any log loss makes
38
+ * the web UI's model selector permanently gray until the worker
39
+ * reconnects. Lives on the bus so it is dropped when the session is
40
+ * deleted (removeEventBus) and survives clearHistory().
41
+ */
42
+ modelsStateSnapshot: Record<string, unknown> | null = null
33
43
 
34
44
  subscribe(callback: Subscriber): () => void {
35
45
  this.subscribers.add(callback)