fix: show multi-run sessions immediately
Register created multi-run sessions in sidebar state Preserve reduced startup refresh fanout Add regression coverage
This commit is contained in:
@@ -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<Session>) => {
|
||||||
|
const previous = currentDirectory;
|
||||||
|
currentDirectory = directory;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
currentDirectory = previous;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createSession: async (params?: { title?: string }): Promise<Session> => ({
|
||||||
|
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> | 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store';
|
import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||||
@@ -11,7 +12,9 @@ import { checkIsGitRepository } from '@/lib/gitApi';
|
|||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
import { useProjectsStore } from './useProjectsStore';
|
import { useProjectsStore } from './useProjectsStore';
|
||||||
import { useSnippetsStore } from './useSnippetsStore';
|
import { useSnippetsStore } from './useSnippetsStore';
|
||||||
|
import { useGlobalSessionsStore } from './useGlobalSessionsStore';
|
||||||
import { getMultiRunSessionTitle } from '@/lib/multirun/title';
|
import { getMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||||
|
import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs';
|
||||||
|
|
||||||
const toGitSafeSlug = (value: string): string => {
|
const toGitSafeSlug = (value: string): string => {
|
||||||
return value
|
return value
|
||||||
@@ -31,6 +34,51 @@ const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string
|
|||||||
return `${groupSlug}/${modelSlug}`;
|
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 resolveActiveProject = (): ProjectRef | null => {
|
||||||
const projectsState = useProjectsStore.getState();
|
const projectsState = useProjectsStore.getState();
|
||||||
const activeProjectId = projectsState.activeProjectId;
|
const activeProjectId = projectsState.activeProjectId;
|
||||||
@@ -165,6 +213,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
directory,
|
directory,
|
||||||
() => opencodeClient.createSession({ title: sessionTitle }),
|
() => opencodeClient.createSession({ title: sessionTitle }),
|
||||||
);
|
);
|
||||||
|
registerCreatedSession(session, directory);
|
||||||
|
|
||||||
createdRuns.push({
|
createdRuns.push({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
@@ -198,6 +247,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
worktreeMetadata.path,
|
worktreeMetadata.path,
|
||||||
() => opencodeClient.createSession({ title: sessionTitle }),
|
() => opencodeClient.createSession({ title: sessionTitle }),
|
||||||
);
|
);
|
||||||
|
registerCreatedSession(session, worktreeMetadata.path);
|
||||||
|
|
||||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
|
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user