Merge origin/main into deferred OpenCode restart branch
This commit is contained in:
@@ -21,6 +21,9 @@ export interface AttachedFile {
|
||||
serverPath?: string;
|
||||
vscodePath?: string;
|
||||
vscodeSource?: 'file' | 'selection';
|
||||
/** Shared ID linking entries extracted from the same document (PPTX, DOCX, etc.).
|
||||
* Removing any entry with this ID cascades to all entries in the group. */
|
||||
sourceDocumentId?: string;
|
||||
}
|
||||
|
||||
export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full';
|
||||
|
||||
@@ -522,6 +522,73 @@ describe('useConfigStore provider persistence', () => {
|
||||
expect(state.currentVariant).toBe('high');
|
||||
});
|
||||
|
||||
test('[issue-2404] setAgent keeps session model override over agent default model', () => {
|
||||
// Custom agent default is model-a; user manually overrode to model-b for this session.
|
||||
// Re-applying setAgent (e.g. after delegated subtask completion rematerializes the
|
||||
// parent) must keep model-b rather than resetting to the agent pin.
|
||||
const sessionId = 'ses_2404_model_override';
|
||||
const multiModelProvider = {
|
||||
...provider('provider', 'model-a'),
|
||||
models: [
|
||||
provider('provider', 'model-a').models[0],
|
||||
provider('provider', 'model-b').models[0],
|
||||
],
|
||||
};
|
||||
useSessionUIStore.setState({ currentSessionId: sessionId });
|
||||
useSelectionStore.getState().saveSessionModelSelection(sessionId, 'provider', 'model-b');
|
||||
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'custom-agent', 'provider', 'model-b');
|
||||
useConfigStore.setState({
|
||||
activeDirectoryKey: DIRECTORY,
|
||||
providers: [multiModelProvider],
|
||||
agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })],
|
||||
currentProviderId: 'provider',
|
||||
currentModelId: 'model-b',
|
||||
currentAgentName: 'custom-agent',
|
||||
selectionSource: 'manual',
|
||||
currentVariant: undefined,
|
||||
directoryScoped: {},
|
||||
});
|
||||
|
||||
useConfigStore.getState().setAgent('custom-agent');
|
||||
|
||||
const state = useConfigStore.getState();
|
||||
expect(state.currentProviderId).toBe('provider');
|
||||
expect(state.currentModelId).toBe('model-b');
|
||||
expect(useSelectionStore.getState().getAgentModelForSession(sessionId, 'custom-agent')).toEqual({
|
||||
providerId: 'provider',
|
||||
modelId: 'model-b',
|
||||
});
|
||||
});
|
||||
|
||||
test('[issue-2404] setAgent uses agent default when no session override exists', () => {
|
||||
const sessionId = 'ses_2404_agent_default';
|
||||
const multiModelProvider = {
|
||||
...provider('provider', 'model-a'),
|
||||
models: [
|
||||
provider('provider', 'model-a').models[0],
|
||||
provider('provider', 'model-b').models[0],
|
||||
],
|
||||
};
|
||||
useSessionUIStore.setState({ currentSessionId: sessionId });
|
||||
useConfigStore.setState({
|
||||
activeDirectoryKey: DIRECTORY,
|
||||
providers: [multiModelProvider],
|
||||
agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })],
|
||||
currentProviderId: 'provider',
|
||||
currentModelId: 'model-b',
|
||||
currentAgentName: undefined,
|
||||
selectionSource: 'auto',
|
||||
currentVariant: undefined,
|
||||
directoryScoped: {},
|
||||
});
|
||||
|
||||
useConfigStore.getState().setAgent('custom-agent');
|
||||
|
||||
const state = useConfigStore.getState();
|
||||
expect(state.currentProviderId).toBe('provider');
|
||||
expect(state.currentModelId).toBe('model-a');
|
||||
});
|
||||
|
||||
test('loadAgents does not fetch OpenCode config directly', async () => {
|
||||
useConfigStore.setState({
|
||||
activeDirectoryKey: DIRECTORY,
|
||||
|
||||
@@ -2503,20 +2503,13 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Prefer the selected agent's configured model when switching agents.
|
||||
const agent = agents.find((candidate) => candidate.name === agentName);
|
||||
const agentModelSelection = agent?.model;
|
||||
if (agentModelSelection?.providerID && agentModelSelection?.modelID) {
|
||||
const { providerID, modelID } = agentModelSelection;
|
||||
const agentProvider = providers.find((provider) => provider.id === providerID);
|
||||
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
||||
|
||||
if (agentModel) {
|
||||
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer a session-level manual override for this agent over the
|
||||
// agent's configured default. Re-applying setAgent after subtask
|
||||
// completion / rematerialization must not clobber the override
|
||||
// (issue #2404). Explicit agent-picker switches still force the
|
||||
// agent default via ModelControls' shouldPreferAgentModel path.
|
||||
if (currentSessionId) {
|
||||
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
||||
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
|
||||
@@ -2532,6 +2525,19 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// No session override — use the agent's configured/pinned model.
|
||||
const agentModelSelection = agent?.model;
|
||||
if (agentModelSelection?.providerID && agentModelSelection?.modelID) {
|
||||
const { providerID, modelID } = agentModelSelection;
|
||||
const agentProvider = providers.find((provider) => provider.id === providerID);
|
||||
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
||||
|
||||
if (agentModel) {
|
||||
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the agent has no preferred model, use settings default.
|
||||
if (settingsDefaultModel) {
|
||||
const parsed = parseModelString(settingsDefaultModel);
|
||||
|
||||
@@ -29,6 +29,20 @@ describe('useFilesViewTabsStore', () => {
|
||||
expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual(['/repo/src']);
|
||||
});
|
||||
|
||||
test('rejects realpath children of workspace symlinks (issue 2627)', () => {
|
||||
const root = '/workspace';
|
||||
const store = useFilesViewTabsStore.getState();
|
||||
|
||||
store.toggleExpandedPath(root, '/workspace/pkg');
|
||||
store.toggleExpandedPath(root, '/real/pkg/src');
|
||||
store.toggleExpandedPath(root, '/workspace/pkg/src');
|
||||
|
||||
expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual([
|
||||
'/workspace/pkg',
|
||||
'/workspace/pkg/src',
|
||||
]);
|
||||
});
|
||||
|
||||
test('removes stale expanded paths by prefix without closing files', () => {
|
||||
const root = '/repo';
|
||||
const store = useFilesViewTabsStore.getState();
|
||||
|
||||
@@ -123,3 +123,38 @@ describe('terminal state reconciliation', () => {
|
||||
expect(useTerminalStore.getState().buffers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default terminal tab labels', () => {
|
||||
afterEach(() => useTerminalStore.getState().clearAll());
|
||||
|
||||
const labels = () =>
|
||||
useTerminalStore.getState().getDirectoryState('/repo')!.tabs.map((tab) => tab.label);
|
||||
|
||||
// Regression for https://github.com/openchamber/openchamber/issues/2718
|
||||
test('does not reuse the number of a closed tab', () => {
|
||||
const first = setup();
|
||||
useTerminalStore.getState().createTab('/repo');
|
||||
expect(labels()).toEqual(['Terminal', 'Terminal 2']);
|
||||
|
||||
useTerminalStore.getState().closeTab('/repo', first);
|
||||
useTerminalStore.getState().createTab('/repo');
|
||||
|
||||
expect(labels()).toEqual(['Terminal 2', 'Terminal 3']);
|
||||
});
|
||||
|
||||
test('numbers past a user-renamed "Terminal N" label instead of duplicating it', () => {
|
||||
const first = setup();
|
||||
useTerminalStore.getState().setTabLabel('/repo', first, 'Terminal 5');
|
||||
useTerminalStore.getState().createTab('/repo');
|
||||
|
||||
expect(labels()).toEqual(['Terminal 5', 'Terminal 6']);
|
||||
});
|
||||
|
||||
test('ignores custom labels and starts over at "Terminal" when no default-labeled tabs remain', () => {
|
||||
const first = setup();
|
||||
useTerminalStore.getState().setTabLabel('/repo', first, 'build');
|
||||
useTerminalStore.getState().createTab('/repo');
|
||||
|
||||
expect(labels()).toEqual(['build', 'Terminal']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,27 @@ function normalizeDirectory(dir: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_LABEL_PATTERN = /^Terminal(?: (\d+))?$/;
|
||||
|
||||
/**
|
||||
* Default labels must stay unique among the directory's open tabs even after
|
||||
* closes (#2718), so number from the highest existing "Terminal N" suffix
|
||||
* instead of the live tab count. Labels are persisted with the tabs, so the
|
||||
* derivation also survives reloads without a dedicated counter. User-renamed
|
||||
* labels only participate when they match the default pattern; they are never
|
||||
* rewritten.
|
||||
*/
|
||||
const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => {
|
||||
let highest = 0;
|
||||
for (const tab of tabs) {
|
||||
const match = DEFAULT_TAB_LABEL_PATTERN.exec(tab.label);
|
||||
if (!match) continue;
|
||||
const value = match[1] ? Number.parseInt(match[1], 10) : 1;
|
||||
if (Number.isSafeInteger(value)) highest = Math.max(highest, value);
|
||||
}
|
||||
return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`;
|
||||
};
|
||||
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
@@ -295,8 +316,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const existing = newSessions.get(key);
|
||||
|
||||
const nextTabId = state.nextTabId + 1;
|
||||
const labelIndex = (existing?.tabs.length ?? 0) + 1;
|
||||
const label = `Terminal ${labelIndex}`;
|
||||
const label = nextDefaultTabLabel(existing?.tabs ?? []);
|
||||
const tab = createEmptyTab(tabId, label);
|
||||
|
||||
if (!existing) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useUIStore } from './useUIStore';
|
||||
|
||||
const initialTemplates = useUIStore.getState().notificationTemplates;
|
||||
|
||||
afterEach(() => {
|
||||
useUIStore.setState({ notificationTemplates: initialTemplates });
|
||||
});
|
||||
|
||||
describe('useUIStore notification templates', () => {
|
||||
test('preserves rapid updates to separate template fields', () => {
|
||||
const { setNotificationTemplates } = useUIStore.getState();
|
||||
|
||||
setNotificationTemplates((current) => ({
|
||||
...current,
|
||||
completion: { ...current.completion, title: 'Completed' },
|
||||
}));
|
||||
setNotificationTemplates((current) => ({
|
||||
...current,
|
||||
error: { ...current.error, message: 'Failed' },
|
||||
}));
|
||||
|
||||
expect(useUIStore.getState().notificationTemplates).toEqual({
|
||||
...initialTemplates,
|
||||
completion: { ...initialTemplates.completion, title: 'Completed' },
|
||||
error: { ...initialTemplates.error, message: 'Failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -845,7 +845,9 @@ interface UIStore {
|
||||
setNotifyOnCompletion: (value: boolean) => void;
|
||||
setNotifyOnError: (value: boolean) => void;
|
||||
setNotifyOnQuestion: (value: boolean) => void;
|
||||
setNotificationTemplates: (templates: UIStore['notificationTemplates']) => void;
|
||||
setNotificationTemplates: (
|
||||
templates: UIStore['notificationTemplates'] | ((current: UIStore['notificationTemplates']) => UIStore['notificationTemplates']),
|
||||
) => void;
|
||||
setSummarizeLastMessage: (value: boolean) => void;
|
||||
setSummaryThreshold: (value: number) => void;
|
||||
setSummaryLength: (value: number) => void;
|
||||
@@ -2143,7 +2145,13 @@ export const useUIStore = create<UIStore>()(
|
||||
setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); },
|
||||
setNotifyOnError: (value) => { set({ notifyOnError: value }); },
|
||||
setNotifyOnQuestion: (value) => { set({ notifyOnQuestion: value }); },
|
||||
setNotificationTemplates: (templates) => { set({ notificationTemplates: templates }); },
|
||||
setNotificationTemplates: (templates) => {
|
||||
set((state) => ({
|
||||
notificationTemplates: typeof templates === 'function'
|
||||
? templates(state.notificationTemplates)
|
||||
: templates,
|
||||
}));
|
||||
},
|
||||
setSummarizeLastMessage: (value) => { set({ summarizeLastMessage: value }); },
|
||||
setSummaryThreshold: (value) => { set({ summaryThreshold: value }); },
|
||||
setSummaryLength: (value) => { set({ summaryLength: value }); },
|
||||
|
||||
Reference in New Issue
Block a user