Fold the assistant's panels away, and name the default it will use

Quick prompts and the assistant model panel both start collapsed now: the
prompt box is what the copilot is opened for, and everything else is a
detour until asked for. A saved session still reopens with whatever was
last expanded, the same as the other collapsible sections.

The assistant model panel gains the toggle it was already written for -
modelPickerOpen and toggleModelPicker existed, were persisted in the
session snapshot, and were never wired to the template, so the panel could
not be folded at all.

Ticking Use default configuration used to leave the panel empty: it said a
default applied without saying which. The backend has been publishing
defaultProvider, defaultModel and defaultPhaseModels alongside the
retriever URLs all along, so nothing new was needed there - only the
showing. The effective selection also appears in the section header, so
the collapsed panel still answers what the run will use, and the per-phase
lines list only the phases actually overridden rather than three rows
repeating the main model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-07 12:30:03 +02:00
parent 37bf2cb853
commit 9e42ca154a
4 changed files with 217 additions and 2 deletions

View File

@ -242,6 +242,20 @@
gap: 8px;
}
/* What the backend will use when nothing is chosen here. */
.assistant-defaults {
display: grid;
gap: 2px;
padding: 8px 10px;
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #f8fafc;
}
.assistant-defaults .assistant-meta {
margin: 0;
}
.assistant-add-credential {
justify-self: start;
}

View File

@ -43,10 +43,23 @@
<div class="assistant-starters-head">
<div>
<p class="assistant-label">Assistant model</p>
<!-- Says what the run will use, so the panel does not have to be opened to find out. -->
@if (effectiveModelSummary(); as summary) {
<p class="assistant-copy">{{ summary }}</p>
} @else {
<p class="assistant-copy">Provider credentials are managed by the backend.</p>
}
</div>
<button
type="button"
class="assistant-section-toggle"
(click)="toggleModelPicker()"
[attr.aria-label]="modelPickerOpen() ? 'Collapse assistant model' : 'Expand assistant model'">
{{ modelPickerOpen() ? '−' : '+' }}
</button>
</div>
@if (modelPickerOpen()) {
<mat-checkbox
[checked]="useDefaultConfiguration()"
[disabled]="configurationLocked() || assistantBusy()"
@ -58,6 +71,19 @@
<p class="assistant-meta">The LLM configuration is locked for this session.</p>
}
@if (useDefaultConfiguration()) {
<div class="assistant-defaults">
@if (defaultConfigurationSummary(); as summary) {
<p class="assistant-meta"><strong>{{ summary }}</strong></p>
} @else {
<p class="assistant-meta">The backend has not published a default provider and model.</p>
}
@for (phase of defaultPhaseModelSummary(); track phase.label) {
<p class="assistant-meta">{{ phase.label }}: {{ phase.model }}</p>
}
</div>
}
@if (!useDefaultConfiguration()) {
<div class="assistant-settings-fields">
<mat-form-field appearance="outline">
@ -188,6 +214,7 @@
</div>
}
}
}
</section>
@if (assistantErrorMessage()) {

View File

@ -259,3 +259,145 @@ describe('FlowAssistant credentials', () => {
expect(provider.options).toEqual([{ label: 'Gemini', value: 'Gemini' }]);
});
});
describe('FlowAssistant quick prompts', () => {
function build() {
const assistant = {
getConfig: vi.fn(() => of({
defaultProvider: '',
defaultModel: '',
availableProvidersRetrieverUrl: '/retriever/LLM/providers',
availableModelsRetrieverUrl: '/retriever/LLM/models?provider={provider}',
defaultPhaseModels: {},
providerCatalogUrl: '/llm/providers'
})),
listProviders: vi.fn(() => of([])),
listModels: vi.fn(() => of([])),
areModelsOpen: vi.fn(() => of(false)),
listProviderCatalog: vi.fn(() => of([]))
};
TestBed.configureTestingModule({
providers: [
{ provide: AssistantService, useValue: assistant },
{ provide: NodeSettingsDialogService, useValue: { open: vi.fn((_input: unknown) => Promise.resolve(null)) } },
{ provide: VaultService, useValue: { listSecrets: vi.fn(() => of([])) } },
{
provide: EditorStateHolder,
useValue: { currentFlow: vi.fn(() => null), activeFlowData: vi.fn(() => null) }
},
{ provide: Authorization, useValue: { currentUser: vi.fn(() => null) } },
{
provide: AssistantSessionStore,
useValue: {
flowKey: vi.fn(() => 'flow-1'),
getSnapshot: vi.fn(() => null),
setSnapshot: vi.fn(),
clearSnapshot: vi.fn(),
cloneSnapshot: vi.fn((snapshot: unknown) => snapshot)
}
}
]
});
return TestBed.createComponent(FlowAssistant).componentInstance as any;
}
afterEach(() => TestBed.resetTestingModule());
it('starts collapsed, like the other collapsible sections', () => {
expect(build().quickPromptsOpen()).toBe(false);
});
it('starts with the assistant model panel collapsed too', () => {
// The signal and its toggle already existed; the template never used them, so the panel was
// permanently expanded and there was no way to fold it away.
expect(build().modelPickerOpen()).toBe(false);
});
it('stays collapsed when a flow with no saved session is opened', async () => {
const component = build();
component.quickPromptsOpen.set(true);
await component.restoreSessionForFlow('flow-2');
expect(component.quickPromptsOpen()).toBe(false);
});
});
describe('FlowAssistant default configuration', () => {
function build(config: Record<string, unknown>) {
const assistant = {
getConfig: vi.fn(() => of(config)),
listProviders: vi.fn(() => of([])),
listModels: vi.fn(() => of([])),
areModelsOpen: vi.fn(() => of(false)),
listProviderCatalog: vi.fn(() => of([]))
};
TestBed.configureTestingModule({
providers: [
{ provide: AssistantService, useValue: assistant },
{ provide: NodeSettingsDialogService, useValue: { open: vi.fn((_input: unknown) => Promise.resolve(null)) } },
{ provide: VaultService, useValue: { listSecrets: vi.fn(() => of([])) } },
{
provide: EditorStateHolder,
useValue: { currentFlow: vi.fn(() => null), activeFlowData: vi.fn(() => null) }
},
{ provide: Authorization, useValue: { currentUser: vi.fn(() => null) } },
{
provide: AssistantSessionStore,
useValue: {
flowKey: vi.fn(() => 'flow-1'),
getSnapshot: vi.fn(() => null),
setSnapshot: vi.fn(),
clearSnapshot: vi.fn(),
cloneSnapshot: vi.fn((snapshot: unknown) => snapshot)
}
}
]
});
const component = TestBed.createComponent(FlowAssistant).componentInstance as any;
component.assistantConfig.set(config);
return component;
}
afterEach(() => TestBed.resetTestingModule());
it('names the backend default while Use default configuration is on', () => {
// Ticking the box said a default applied without ever saying which one.
const component = build({ defaultProvider: 'InternalOllama', defaultModel: 'llama3.2:3b', defaultPhaseModels: {} });
expect(component.useDefaultConfiguration()).toBe(true);
expect(component.defaultConfigurationSummary()).toBe('InternalOllama \u00b7 llama3.2:3b');
expect(component.effectiveModelSummary()).toBe('Default: InternalOllama \u00b7 llama3.2:3b');
});
it('lists only the phases the backend actually overrides', () => {
const component = build({
defaultProvider: 'InternalOllama',
defaultModel: 'llama3.2:3b',
defaultPhaseModels: { planningModel: 'qwen2.5:7b', jsonModel: ' ', repairModel: undefined }
});
expect(component.defaultPhaseModelSummary()).toEqual([{ label: 'Planning', model: 'qwen2.5:7b' }]);
});
it('says nothing rather than something empty when no default was published', () => {
const component = build({ defaultProvider: '', defaultModel: '', defaultPhaseModels: {} });
expect(component.defaultConfigurationSummary()).toBeNull();
expect(component.effectiveModelSummary()).toBeNull();
});
it('summarises the chosen provider and model once the default is turned off', () => {
const component = build({ defaultProvider: 'InternalOllama', defaultModel: 'llama3.2:3b', defaultPhaseModels: {} });
component.useDefaultConfiguration.set(false);
component.selectedProvider.set('Gemini');
component.selectedModel.set('gemini-2.5-flash');
expect(component.effectiveModelSummary()).toBe('Gemini \u00b7 gemini-2.5-flash');
});
});

View File

@ -84,7 +84,8 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly credentialSaving = signal(false);
readonly advancedModelsOpen = signal(false);
readonly modelPickerOpen = signal(false);
readonly quickPromptsOpen = signal(true);
/** Closed on arrival, like the other collapsible sections: the prompt box is what you came for. */
readonly quickPromptsOpen = signal(false);
readonly assistantErrorMessage = signal<string | null>(null);
readonly lastFailedPrompt = signal<string | null>(null);
readonly lastSubmittedPrompt = signal('');
@ -205,6 +206,37 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly currentFlow = this.editorState.currentFlow;
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
readonly configurationLocked = computed(() => !!this.sessionState()?.id);
/**
* What the backend says it will use when nothing is chosen here. It has always been in the
* config payload alongside the retriever URLs; the panel just never showed it, so ticking Use
* default configuration told you a default applied without ever saying which.
*/
readonly defaultConfigurationSummary = computed(() => {
const config = this.assistantConfig();
const parts = [config?.defaultProvider?.trim(), config?.defaultModel?.trim()].filter((part) => !!part);
return parts.length ? parts.join(' \u00b7 ') : null;
});
/** The phases the backend overrides, if any. Silence here means they all use the main model. */
readonly defaultPhaseModelSummary = computed(() => {
const phases = this.assistantConfig()?.defaultPhaseModels;
return [
{ label: 'Planning', model: phases?.planningModel?.trim() },
{ label: 'JSON', model: phases?.jsonModel?.trim() },
{ label: 'Repair', model: phases?.repairModel?.trim() }
].filter((entry): entry is { label: string; model: string } => !!entry.model);
});
/** What the run will use, readable with the panel collapsed. */
readonly effectiveModelSummary = computed(() => {
if (this.useDefaultConfiguration()) {
const summary = this.defaultConfigurationSummary();
return summary ? `Default: ${summary}` : null;
}
const parts = [this.selectedProvider().trim(), this.selectedModel().trim()].filter((part) => !!part);
return parts.length ? parts.join(' \u00b7 ') : null;
});
readonly providerNeedsCredential = computed(() =>
!!this.selectedProvider().trim() && !this.isInternalProvider(this.selectedProvider())
);
@ -892,7 +924,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
if (!snapshot) {
this.prompt.set('');
this.modelPickerOpen.set(false);
this.quickPromptsOpen.set(true);
this.quickPromptsOpen.set(false);
this.sessionState.set(null);
this.localMessages.set([]);
this.assistantErrorMessage.set(null);