From 25ed8e6abbe8809a23519203e6e37ffdbbcf9711 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 25 May 2026 17:37:27 +0300 Subject: [PATCH] fix: show multi-run sessions immediately Register created multi-run sessions in sidebar state Preserve reduced startup refresh fanout Add regression coverage --- .../ui/src/stores/useMultiRunStore.test.ts | 142 ++++++++++++++++++ packages/ui/src/stores/useMultiRunStore.ts | 50 ++++++ 2 files changed, 192 insertions(+) create mode 100644 packages/ui/src/stores/useMultiRunStore.test.ts diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts new file mode 100644 index 00000000..b3ef3f00 --- /dev/null +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +const upsertedSessions: Session[] = []; +const registeredDirectories: Array<{ sessionID: string; directory: string }> = []; +const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = []; +const childState = { + session: [] as Session[], + sessionTotal: 0, + limit: 5, +}; +let currentDirectory = '/repo'; + +mock.module('@/sync/session-ui-store', () => ({ + routeMessage: mock(() => Promise.resolve()), + useSessionUIStore: { + getState: () => ({ + markSessionAsOpenChamberCreated: mock(() => undefined), + }), + }, +})); + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + withDirectory: async (directory: string, fn: () => Promise) => { + const previous = currentDirectory; + currentDirectory = directory; + try { + return await fn(); + } finally { + currentDirectory = previous; + } + }, + createSession: async (params?: { title?: string }): Promise => ({ + id: 'ses_multirun', + title: params?.title ?? '', + directory: currentDirectory, + time: { created: 1, updated: 1 }, + } as Session), + }, +})); + +mock.module('@/lib/gitApi', () => ({ + checkIsGitRepository: mock(() => Promise.resolve(false)), +})); + +mock.module('@/lib/worktrees/worktreeCreate', () => ({ + createWorktreeWithDefaults: mock(), + resolveRootTrackingRemote: mock(() => Promise.resolve(null)), +})); + +mock.module('@/lib/worktrees/worktreeStatus', () => ({ + getRootBranch: mock(() => Promise.resolve('main')), +})); + +mock.module('@/lib/openchamberConfig', () => ({ + saveWorktreeSetupCommands: mock(() => Promise.resolve()), +})); + +mock.module('./useDirectoryStore', () => ({ + useDirectoryStore: { + getState: () => ({ currentDirectory: '/repo' }), + }, +})); + +mock.module('./useProjectsStore', () => ({ + useProjectsStore: { + getState: () => ({ + activeProjectId: 'project-1', + projects: [{ id: 'project-1', path: '/repo' }], + }), + }, +})); + +mock.module('./useSnippetsStore', () => ({ + useSnippetsStore: { + getState: () => ({ + expandText: (value: string) => Promise.resolve(value), + }), + }, +})); + +mock.module('./useGlobalSessionsStore', () => ({ + useGlobalSessionsStore: { + getState: () => ({ + upsertSession: (session: Session) => { + upsertedSessions.push(session); + }, + }), + }, +})); + +mock.module('@/sync/sync-refs', () => ({ + registerSessionDirectory: (sessionID: string, directory: string) => { + registeredDirectories.push({ sessionID, directory }); + }, + getSyncChildStores: () => ({ + ensureChild: (directory: string, options?: { bootstrap?: boolean }) => { + ensureChildCalls.push({ directory, bootstrap: options?.bootstrap }); + return { + setState: (updater: typeof childState | ((state: typeof childState) => Partial | typeof childState)) => { + const patch = typeof updater === 'function' ? updater(childState) : updater; + if (patch !== childState) { + Object.assign(childState, patch); + } + }, + }; + }, + }), +})); + +const { useMultiRunStore } = await import('./useMultiRunStore'); + +describe('useMultiRunStore', () => { + beforeEach(() => { + upsertedSessions.length = 0; + registeredDirectories.length = 0; + ensureChildCalls.length = 0; + childState.session = []; + childState.sessionTotal = 0; + childState.limit = 5; + currentDirectory = '/repo'; + useMultiRunStore.setState({ isLoading: false, error: null }); + }); + + test('registers created sessions without waiting for a sidebar refresh', async () => { + const result = await useMultiRunStore.getState().createMultiRun({ + name: 'Fix thing', + isolateRuns: false, + groups: [{ + prompt: 'Fix it', + models: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }], + }], + }); + + expect(result?.sessionIds).toEqual(['ses_multirun']); + expect(upsertedSessions.map((session) => session.id)).toEqual(['ses_multirun']); + expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo' }]); + expect(ensureChildCalls).toEqual([{ directory: '/repo', bootstrap: false }]); + expect(childState.session.map((session) => session.id)).toEqual(['ses_multirun']); + }); +}); diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 0fbd16a8..a5b856d0 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import type { Session } from '@opencode-ai/sdk/v2'; import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store'; import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; @@ -11,7 +12,9 @@ import { checkIsGitRepository } from '@/lib/gitApi'; import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; import { useSnippetsStore } from './useSnippetsStore'; +import { useGlobalSessionsStore } from './useGlobalSessionsStore'; import { getMultiRunSessionTitle } from '@/lib/multirun/title'; +import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs'; const toGitSafeSlug = (value: string): string => { return value @@ -31,6 +34,51 @@ const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string return `${groupSlug}/${modelSlug}`; }; +const normalizePath = (value: string): string => { + const replaced = value.replace(/\\/g, '/'); + if (replaced === '/') { + return '/'; + } + return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; +}; + +const registerCreatedSession = (session: Session, directory: string): Session => { + const normalizedDirectory = normalizePath(directory); + const sessionDirectory = (session as Session & { directory?: string | null }).directory; + const sessionWithDirectory = typeof sessionDirectory === 'string' && sessionDirectory.trim().length > 0 + ? session + : ({ ...session, directory: normalizedDirectory } as Session); + + registerSessionDirectory(session.id, normalizedDirectory); + useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id); + useGlobalSessionsStore.getState().upsertSession(sessionWithDirectory); + + try { + const store = getSyncChildStores().ensureChild(normalizedDirectory, { bootstrap: false }); + store.setState((state) => { + const existingIndex = state.session.findIndex((candidate) => candidate.id === session.id); + if (existingIndex >= 0 && state.session[existingIndex] === sessionWithDirectory) { + return state; + } + + const nextSessions = existingIndex >= 0 + ? state.session.map((candidate, index) => index === existingIndex ? sessionWithDirectory : candidate) + : [...state.session, sessionWithDirectory].sort((a, b) => a.id.localeCompare(b.id)); + + return { + session: nextSessions, + sessionTotal: Math.max(state.sessionTotal, nextSessions.length), + limit: Math.max(state.limit, nextSessions.length), + }; + }); + } catch { + // SyncProvider can be unavailable in tests or detached surfaces; the global + // session upsert above is enough for the sidebar to show the session. + } + + return sessionWithDirectory; +}; + const resolveActiveProject = (): ProjectRef | null => { const projectsState = useProjectsStore.getState(); const activeProjectId = projectsState.activeProjectId; @@ -165,6 +213,7 @@ export const useMultiRunStore = create()( directory, () => opencodeClient.createSession({ title: sessionTitle }), ); + registerCreatedSession(session, directory); createdRuns.push({ sessionId: session.id, @@ -198,6 +247,7 @@ export const useMultiRunStore = create()( worktreeMetadata.path, () => opencodeClient.createSession({ title: sessionTitle }), ); + registerCreatedSession(session, worktreeMetadata.path); useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);