Skip to content

Commit 865b4e6

Browse files
committed
fix(web): scope a queued capability write to the session that made it
A write that outlives a session switch would be sent against the new active session, and its failure would roll that session's selection back to the old one's snapshot. Both now stop at the session guard. Also proves the toolbar divider sits next to the attach button in the rendered DOM instead of matching two class strings anywhere in the file.
1 parent 4e89892 commit 865b4e6

3 files changed

Lines changed: 47 additions & 2 deletions

File tree

‎apps/pythinker-web/src/components/CapabilityMenu.vue‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,17 @@ const writeCount: Record<CapabilityField, number> = { tools: 0, mcpServers: 0 };
130130
131131
function queueWrite(field: CapabilityField, selection: Ref<string[]>, previous: string[]): Promise<void> {
132132
const seq = ++writeCount[field];
133+
// The write belongs to the session that was rendered when the user toggled.
134+
// `updateCapabilities` always targets the active session, so a queued write
135+
// that outlives a session switch must be dropped, not sent to the new one —
136+
// and its rollback must not touch the new session's selection either.
137+
const sessionId = props.sessionId;
133138
const write = writeChain[field].then(async () => {
139+
if (props.sessionId !== sessionId) return;
134140
try {
135141
await client.updateCapabilities({ [field]: [...selection.value] });
136142
} catch {
137-
if (seq === writeCount[field]) selection.value = previous;
143+
if (seq === writeCount[field] && props.sessionId === sessionId) selection.value = previous;
138144
}
139145
});
140146
writeChain[field] = write;

‎apps/pythinker-web/test/capability-menu.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ afterEach(() => {
136136
document.body.replaceChildren();
137137
vi.restoreAllMocks();
138138
const current = client();
139+
current.activeSessionId.value = 'session_1';
139140
current.activeSessionCapabilities.value = { tools: ['Read'], mcpServers: ['mcp_1'] };
140141
current.toolsBySession.value = {
141142
session_1: [
@@ -327,6 +328,38 @@ describe('CapabilityMenu', () => {
327328
expect(two.getAttribute('aria-checked')).toBe('true');
328329
});
329330

331+
it('drops a queued write and its rollback when the session changes first', async () => {
332+
const current = client();
333+
const pending = deferred();
334+
current.updateCapabilities.mockReturnValueOnce(pending.promise);
335+
336+
const wrapper = mountMenu();
337+
await wrapper.get('.capability-trigger').trigger('click');
338+
await flushPromises();
339+
const toggle = document.body.querySelector('.mcp-row .switch-toggle') as HTMLButtonElement;
340+
341+
toggle.click();
342+
await nextTick();
343+
344+
// The user moves to a session with a different selection while the write is
345+
// still open. `updateCapabilities` targets whichever session is active, so
346+
// the stale failure belongs to a session that is gone; rolling back here
347+
// would show session one's selection under session two.
348+
await wrapper.setProps({ sessionId: 'session_2' });
349+
current.activeSessionId.value = 'session_2';
350+
current.activeSessionCapabilities.value = { tools: ['Read'], mcpServers: ['mcp_2'] };
351+
await nextTick();
352+
353+
pending.reject(new Error('daemon unreachable'));
354+
await flushPromises();
355+
356+
const checked = [...document.body.querySelectorAll('.mcp-row')].map((row) => [
357+
row.textContent?.includes('Docs') === true ? 'Docs' : 'Issue tracker',
358+
row.querySelector('.switch-toggle')?.getAttribute('aria-checked'),
359+
]);
360+
expect(checked).toEqual([['Docs', 'false'], ['Issue tracker', 'true']]);
361+
});
362+
330363
it('renders selected tools and MCP servers as chips', () => {
331364
const wrapper = mountMenu();
332365

‎apps/pythinker-web/test/composer.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,13 @@ describe('Composer styling', () => {
142142
const dividerRule = capture(composerSource, /(?:^|\n)\.toolbar-divider\s*\{([^}]*)\}/u);
143143
expect(dividerRule).toMatch(/width:\s*1px;/u);
144144
expect(dividerRule).toMatch(/height:\s*16px;/u);
145-
expect(composerSource).toMatch(/class="attach-btn"[\s\S]*class="toolbar-divider"/u);
145+
// Rendered, not source order: the divider has to be the attach button's next
146+
// sibling inside the same toolbar, which a source-wide match cannot show.
147+
const wrapper = mountComposer({ uploadImage: async () => ({ fileId: 'file_1' }) });
148+
const attach = wrapper.get('.attach-btn').element;
149+
const divider = wrapper.get('.toolbar-divider').element;
150+
expect(attach.nextElementSibling).toBe(divider);
151+
expect(divider.parentElement).toBe(attach.parentElement);
146152
});
147153

148154
it('pads the send button around a 20px glyph and keeps the accent fill', () => {

0 commit comments

Comments
 (0)