Skip to content

Commit 2e67a2e

Browse files
author
IM.codes
committed
fix(server): restore quick data edits and push titles
1 parent 4e05fac commit 2e67a2e

File tree

5 files changed

+287
-39
lines changed

5 files changed

+287
-39
lines changed

server/src/routes/quick-data.ts

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,7 @@ quickDataRoutes.get('/', async (c) => {
2222
return c.json({ data });
2323
});
2424

25-
/** Merge two string arrays: deduplicate, preserve order (incoming first), cap at max. */
26-
function mergeArrays(incoming: string[], existing: string[], max: number): string[] {
27-
const seen = new Set<string>();
28-
const result: string[] = [];
29-
for (const s of [...incoming, ...existing]) {
30-
if (!seen.has(s)) {
31-
seen.add(s);
32-
result.push(s);
33-
if (result.length >= max) break;
34-
}
35-
}
36-
return result;
37-
}
38-
39-
/** PUT /api/quick-data — merge with existing data (not replace) */
25+
/** PUT /api/quick-data — replace the user's quick data snapshot */
4026
quickDataRoutes.put('/', async (c) => {
4127
const userId = c.get('userId' as never) as string;
4228

@@ -52,24 +38,13 @@ quickDataRoutes.put('/', async (c) => {
5238
return c.json({ error: 'invalid_data', detail: parsed.error.flatten() }, 400);
5339
}
5440

55-
// Read existing data and merge to avoid cross-device overwrites
56-
const existing = await getQuickData(c.env.DB, userId);
57-
const merged = {
58-
history: mergeArrays(parsed.data.history, existing.history ?? [], 50),
59-
sessionHistory: { ...existing.sessionHistory, ...parsed.data.sessionHistory } as Record<string, string[]>,
60-
commands: mergeArrays(parsed.data.commands, existing.commands ?? [], 200),
61-
phrases: mergeArrays(parsed.data.phrases, existing.phrases ?? [], 200),
41+
const next = {
42+
history: parsed.data.history,
43+
sessionHistory: parsed.data.sessionHistory,
44+
commands: parsed.data.commands,
45+
phrases: parsed.data.phrases,
6246
};
6347

64-
// Merge per-session histories too
65-
for (const [key, arr] of Object.entries(existing.sessionHistory ?? {})) {
66-
if (merged.sessionHistory[key]) {
67-
merged.sessionHistory[key] = mergeArrays(merged.sessionHistory[key], arr, 50);
68-
} else {
69-
merged.sessionHistory[key] = arr;
70-
}
71-
}
72-
73-
await upsertQuickData(c.env.DB, userId, merged);
48+
await upsertQuickData(c.env.DB, userId, next);
7449
return c.json({ ok: true });
7550
});

server/src/ws/bridge.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2158,6 +2158,8 @@ export class WsBridge {
21582158

21592159
// Look up session metadata for human-readable push content
21602160
// Check sessions table first, then sub_sessions for sub-session names
2161+
const activeMainSession = this.activeMainSessions.get(sessionName);
2162+
21612163
let sessionRow = await db.queryOne<{ project_name: string; agent_type: string; label: string | null }>(
21622164
'SELECT project_name, agent_type, label FROM sessions WHERE server_id = $1 AND name = $2 LIMIT 1',
21632165
[this.serverId, sessionName],
@@ -2166,7 +2168,7 @@ export class WsBridge {
21662168
let subType: string | undefined;
21672169
if (!sessionRow) {
21682170
// Try sub_sessions table: session name is deck_sub_{id}
2169-
const subMatch = sessionName.match(/^deck_sub_([a-zA-Z0-9]+)$/);
2171+
const subMatch = sessionName.match(/^deck_sub_(.+)$/);
21702172
const subRow = subMatch ? await db.queryOne<{ type: string; label: string | null; parent_session: string }>(
21712173
'SELECT type, label, parent_session FROM sub_sessions WHERE server_id = $1 AND id = $2 LIMIT 1',
21722174
[this.serverId, subMatch[1]],
@@ -2175,23 +2177,26 @@ export class WsBridge {
21752177
subType = subRow.type;
21762178
// Look up parent session for context
21772179
if (!daemonParentLabel && subRow.parent_session) {
2180+
const activeParent = this.activeMainSessions.get(subRow.parent_session);
21782181
const parentRow = await db.queryOne<{ project_name: string; label: string | null }>(
21792182
'SELECT project_name, label FROM sessions WHERE server_id = $1 AND name = $2 LIMIT 1',
21802183
[this.serverId, subRow.parent_session],
21812184
).catch(() => null);
2182-
if (parentRow) {
2183-
sessionRow = { project_name: parentRow.project_name, agent_type: subRow.type, label: subRow.label };
2184-
}
2185+
sessionRow = {
2186+
project_name: activeParent?.project || parentRow?.project_name || subRow.parent_session,
2187+
agent_type: subRow.type,
2188+
label: subRow.label,
2189+
};
21852190
}
21862191
}
21872192
}
21882193

21892194
// Push title uses a stable 3-part shape: server · displayLabel · agentType.
21902195
// sessionName is the final fallback only when no label/project-style name exists.
2191-
const label = daemonLabel || sessionRow?.label;
2192-
const agentType = subType || sessionRow?.agent_type || String(msg.agentType ?? '');
2196+
const label = daemonLabel || activeMainSession?.label || sessionRow?.label;
2197+
const agentType = subType || activeMainSession?.agentType || sessionRow?.agent_type || String(msg.agentType ?? '');
21932198
const isSub = sessionName.startsWith('deck_sub_');
2194-
const mainDisplayName = sessionRow?.project_name || daemonParentLabel || sessionName;
2199+
const mainDisplayName = activeMainSession?.project || sessionRow?.project_name || daemonParentLabel || sessionName;
21952200
const displayName = isSub
21962201
? (label || sessionName)
21972202
: (label || mainDisplayName || sessionName);

server/test/bridge.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,6 +1568,50 @@ describe('WsBridge', () => {
15681568
expect(payload.title).not.toContain('deck_sub_ab12cd34');
15691569
});
15701570

1571+
it('resolves hyphenated sub-session ids before falling back to internal session names', async () => {
1572+
const { dispatchPush } = await import('../src/routes/push.js');
1573+
const { daemonWs } = await setupPushBridge();
1574+
1575+
daemonWs.emit('message', JSON.stringify({
1576+
type: 'session.idle',
1577+
session: 'deck_sub_sub-123',
1578+
lastText: 'Done.',
1579+
}));
1580+
await flushAsync();
1581+
1582+
const payload = vi.mocked(dispatchPush).mock.calls[0][0];
1583+
expect(payload.title).toBe('my-server · worker-1 · codex');
1584+
expect(payload.title).not.toContain('deck_sub_sub-123');
1585+
});
1586+
1587+
it('prefers active session snapshot labels over internal main session names in push title', async () => {
1588+
const { dispatchPush } = await import('../src/routes/push.js');
1589+
const { daemonWs } = await setupPushBridge();
1590+
1591+
daemonWs.emit('message', JSON.stringify({
1592+
type: 'session_list',
1593+
sessions: [{
1594+
name: 'bootmainxowfy6',
1595+
project: 'codedeck',
1596+
state: 'idle',
1597+
agentType: 'claude-code',
1598+
label: 'Boot Main',
1599+
}],
1600+
}));
1601+
await flushAsync();
1602+
1603+
daemonWs.emit('message', JSON.stringify({
1604+
type: 'session.idle',
1605+
session: 'bootmainxowfy6',
1606+
lastText: 'Ready.',
1607+
}));
1608+
await flushAsync();
1609+
1610+
const payload = vi.mocked(dispatchPush).mock.calls.at(-1)?.[0];
1611+
expect(payload?.title).toBe('my-server · Boot Main · claude-code');
1612+
expect(payload?.title).not.toContain('bootmainxowfy6');
1613+
});
1614+
15711615
it('uses lastText as push body for session.idle', async () => {
15721616
const { dispatchPush } = await import('../src/routes/push.js');
15731617
const { daemonWs } = await setupPushBridge();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Hono } from 'hono';
3+
4+
const getQuickDataMock = vi.fn();
5+
const upsertQuickDataMock = vi.fn();
6+
7+
vi.mock('../src/security/authorization.js', () => ({
8+
requireAuth: () => async (c: any, next: any) => {
9+
c.set('userId', 'test-user');
10+
return next();
11+
},
12+
}));
13+
14+
vi.mock('../src/db/queries.js', () => ({
15+
getQuickData: (...args: unknown[]) => getQuickDataMock(...args),
16+
upsertQuickData: (...args: unknown[]) => upsertQuickDataMock(...args),
17+
}));
18+
19+
import { quickDataRoutes } from '../src/routes/quick-data.js';
20+
21+
const app = new Hono();
22+
app.use('/*', async (c, next) => {
23+
(c as any).env = { DB: {} };
24+
return next();
25+
});
26+
app.route('/api/quick-data', quickDataRoutes);
27+
28+
describe('quick-data routes', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
getQuickDataMock.mockResolvedValue({
32+
history: ['keep history'],
33+
sessionHistory: { 'deck_a': ['keep session'] },
34+
commands: ['/status'],
35+
phrases: ['old phrase', 'keep phrase'],
36+
});
37+
});
38+
39+
it('replaces removed custom phrases instead of merging them back', async () => {
40+
const res = await app.request('/api/quick-data', {
41+
method: 'PUT',
42+
headers: { 'Content-Type': 'application/json' },
43+
body: JSON.stringify({
44+
data: {
45+
history: ['keep history'],
46+
sessionHistory: { 'deck_a': ['keep session'] },
47+
commands: ['/status'],
48+
phrases: ['keep phrase'],
49+
},
50+
}),
51+
});
52+
53+
expect(res.status).toBe(200);
54+
expect(upsertQuickDataMock).toHaveBeenCalledWith({}, 'test-user', {
55+
history: ['keep history'],
56+
sessionHistory: { 'deck_a': ['keep session'] },
57+
commands: ['/status'],
58+
phrases: ['keep phrase'],
59+
});
60+
});
61+
62+
it('persists edited custom phrases as replacements', async () => {
63+
const res = await app.request('/api/quick-data', {
64+
method: 'PUT',
65+
headers: { 'Content-Type': 'application/json' },
66+
body: JSON.stringify({
67+
data: {
68+
history: ['keep history'],
69+
sessionHistory: { 'deck_a': ['keep session'] },
70+
commands: ['/status'],
71+
phrases: ['updated phrase'],
72+
},
73+
}),
74+
});
75+
76+
expect(res.status).toBe(200);
77+
expect(upsertQuickDataMock).toHaveBeenCalledWith({}, 'test-user', {
78+
history: ['keep history'],
79+
sessionHistory: { 'deck_a': ['keep session'] },
80+
commands: ['/status'],
81+
phrases: ['updated phrase'],
82+
});
83+
});
84+
});

web/test/components/QuickInputPanel.test.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,144 @@ describe('QuickInputPanel history scope', () => {
8080
expect(screen.getByText('session b newest')).toBeDefined();
8181
expect(screen.getByText('session b older')).toBeDefined();
8282
});
83+
84+
it('removes a custom phrase when its delete action is confirmed', () => {
85+
const removePhrase = vi.fn();
86+
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
87+
const { container } = render(
88+
<QuickInputPanel
89+
open
90+
onClose={vi.fn()}
91+
onSelect={vi.fn()}
92+
onSend={vi.fn()}
93+
agentType="claude-code"
94+
sessionName="session-a"
95+
data={{ history: [], sessionHistory: {}, commands: [], phrases: ['custom phrase'] }}
96+
loaded
97+
onAddCommand={vi.fn()}
98+
onAddPhrase={vi.fn()}
99+
onRemoveCommand={vi.fn()}
100+
onRemovePhrase={removePhrase}
101+
onRemoveHistory={vi.fn()}
102+
onRemoveSessionHistory={vi.fn()}
103+
onClearHistory={vi.fn()}
104+
onClearSessionHistory={vi.fn()}
105+
/>,
106+
);
107+
108+
const deleteButton = container.querySelector('.qp-pill-custom .qp-pill-del') as HTMLButtonElement | null;
109+
expect(deleteButton).not.toBeNull();
110+
fireEvent.click(deleteButton!);
111+
112+
expect(confirmSpy).toHaveBeenCalledWith('Delete?');
113+
expect(removePhrase).toHaveBeenCalledWith('custom phrase');
114+
confirmSpy.mockRestore();
115+
});
116+
117+
it('removes a custom command when its delete action is confirmed', () => {
118+
const removeCommand = vi.fn();
119+
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
120+
const { container } = render(
121+
<QuickInputPanel
122+
open
123+
onClose={vi.fn()}
124+
onSelect={vi.fn()}
125+
onSend={vi.fn()}
126+
agentType="claude-code"
127+
sessionName="session-a"
128+
data={{ history: [], sessionHistory: {}, commands: ['/custom'], phrases: [] }}
129+
loaded
130+
onAddCommand={vi.fn()}
131+
onAddPhrase={vi.fn()}
132+
onRemoveCommand={removeCommand}
133+
onRemovePhrase={vi.fn()}
134+
onRemoveHistory={vi.fn()}
135+
onRemoveSessionHistory={vi.fn()}
136+
onClearHistory={vi.fn()}
137+
onClearSessionHistory={vi.fn()}
138+
/>,
139+
);
140+
141+
const deleteButton = container.querySelector('.qp-pill-custom .qp-pill-del') as HTMLButtonElement | null;
142+
expect(deleteButton).not.toBeNull();
143+
fireEvent.click(deleteButton!);
144+
145+
expect(confirmSpy).toHaveBeenCalledWith('Delete?');
146+
expect(removeCommand).toHaveBeenCalledWith('/custom');
147+
confirmSpy.mockRestore();
148+
});
149+
150+
it('replaces a custom phrase when edited and committed', () => {
151+
const addPhrase = vi.fn();
152+
const removePhrase = vi.fn();
153+
const { container } = render(
154+
<QuickInputPanel
155+
open
156+
onClose={vi.fn()}
157+
onSelect={vi.fn()}
158+
onSend={vi.fn()}
159+
agentType="claude-code"
160+
sessionName="session-a"
161+
data={{ history: [], sessionHistory: {}, commands: [], phrases: ['custom phrase'] }}
162+
loaded
163+
onAddCommand={vi.fn()}
164+
onAddPhrase={addPhrase}
165+
onRemoveCommand={vi.fn()}
166+
onRemovePhrase={removePhrase}
167+
onRemoveHistory={vi.fn()}
168+
onRemoveSessionHistory={vi.fn()}
169+
onClearHistory={vi.fn()}
170+
onClearSessionHistory={vi.fn()}
171+
/>,
172+
);
173+
174+
const editButton = container.querySelector('.qp-pill-custom .qp-pill-edit') as HTMLButtonElement | null;
175+
expect(editButton).not.toBeNull();
176+
fireEvent.click(editButton!);
177+
178+
const input = container.querySelector('.qp-edit-input') as HTMLInputElement | null;
179+
expect(input).not.toBeNull();
180+
fireEvent.input(input!, { target: { value: 'updated phrase' } });
181+
fireEvent.keyDown(input!, { key: 'Enter' });
182+
183+
expect(removePhrase).toHaveBeenCalledWith('custom phrase');
184+
expect(addPhrase).toHaveBeenCalledWith('updated phrase');
185+
});
186+
187+
it('replaces a custom command when edited and committed', () => {
188+
const addCommand = vi.fn();
189+
const removeCommand = vi.fn();
190+
const { container } = render(
191+
<QuickInputPanel
192+
open
193+
onClose={vi.fn()}
194+
onSelect={vi.fn()}
195+
onSend={vi.fn()}
196+
agentType="claude-code"
197+
sessionName="session-a"
198+
data={{ history: [], sessionHistory: {}, commands: ['/custom'], phrases: [] }}
199+
loaded
200+
onAddCommand={addCommand}
201+
onAddPhrase={vi.fn()}
202+
onRemoveCommand={removeCommand}
203+
onRemovePhrase={vi.fn()}
204+
onRemoveHistory={vi.fn()}
205+
onRemoveSessionHistory={vi.fn()}
206+
onClearHistory={vi.fn()}
207+
onClearSessionHistory={vi.fn()}
208+
/>,
209+
);
210+
211+
const editButton = container.querySelector('.qp-pill-custom .qp-pill-edit') as HTMLButtonElement | null;
212+
expect(editButton).not.toBeNull();
213+
fireEvent.click(editButton!);
214+
215+
const input = container.querySelector('.qp-edit-input') as HTMLInputElement | null;
216+
expect(input).not.toBeNull();
217+
fireEvent.input(input!, { target: { value: '/updated' } });
218+
fireEvent.keyDown(input!, { key: 'Enter' });
219+
220+
expect(removeCommand).toHaveBeenCalledWith('/custom');
221+
expect(addCommand).toHaveBeenCalledWith('/updated');
222+
});
83223
});

0 commit comments

Comments
 (0)