Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ <h3 id="skills-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whi
</div>
} @else {
<div class="space-y-3" role="group" aria-labelledby="skills-heading">
@for (skill of skillService.skills(); track skill.skillId) {
@for (skill of skillService.visibleSkills(); track skill.skillId) {
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<label
Expand Down Expand Up @@ -471,7 +471,7 @@ <h3 id="tools-heading" class="text-sm/6 font-medium text-gray-900 dark:text-whit
<div class="text-sm/6 text-gray-500 dark:text-gray-400">No tools available</div>
} @else {
<div class="space-y-3" role="group" aria-labelledby="tools-heading">
@for (tool of toolService.tools(); track tool.toolId) {
@for (tool of toolService.visibleTools(); track tool.toolId) {
<div>
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-1 items-start gap-1.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,19 @@ describe('SkillService', () => {
expect(service.enabledSkillIds()).toEqual(['web_research']);
});

it('filters visibleSkills to only the bound skills while locked', () => {
expect(service.visibleSkills().map(s => s.skillId).sort()).toEqual(['pdf_workflows', 'web_research']);
service.lockToAgentSkills(['web_research']);
expect(service.visibleSkills().map(s => s.skillId)).toEqual(['web_research']);
});

it('restores the user set when cleared', () => {
service.lockToAgentSkills(['web_research']);
service.clearAgentLock();
expect(service.agentLocked()).toBe(false);
// Back to per-skill state: only pdf_workflows is user-enabled.
expect(service.enabledSkillIds()).toEqual(['pdf_workflows']);
expect(service.visibleSkills().length).toBe(2);
});
});

Expand Down
13 changes: 13 additions & 0 deletions frontend/ai.client/src/app/services/skill/skill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ export class SkillService {

readonly hasSkills = computed(() => this._skills().length > 0);

/**
* The skills the picker should render. Agent-locked → only the bound skills
* (the agent dictates a fixed set, so hide the rest); otherwise every
* accessible skill.
*/
readonly visibleSkills = computed(() => {
const locked = this._agentLockedSkillIds();
if (locked !== null) {
return this._skills().filter(s => locked.includes(s.skillId));
}
return this._skills();
});

/**
* Whether a skill row should render as ON. Agent-locked → membership in the
* bound set; otherwise the user's own enabled state.
Expand Down
9 changes: 9 additions & 0 deletions frontend/ai.client/src/app/services/tool/tool.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,21 @@ describe('ToolService', () => {
expect(service.enabledToolIds()).toEqual(['code-interp']);
});

it('filters visibleTools to only the bound tools while locked', () => {
// Unlocked: every accessible tool is visible.
expect(service.visibleTools().map(t => t.toolId).sort()).toEqual(['code-interp', 'search-web']);
service.lockToAgentTools(['code-interp']);
// Locked: only the bound tool is shown (the rest are hidden, not greyed).
expect(service.visibleTools().map(t => t.toolId)).toEqual(['code-interp']);
});

it('restores the user set when cleared', () => {
service.lockToAgentTools(['code-interp']);
service.clearAgentLock();
expect(service.agentLocked()).toBe(false);
// Back to the per-tool computation (both mock tools are enabled).
expect(service.enabledToolIds().sort()).toEqual(['code-interp', 'search-web']);
expect(service.visibleTools().length).toBe(2);
});

it('locks to an empty set (agent with no tools ≠ free-select)', () => {
Expand Down
13 changes: 13 additions & 0 deletions frontend/ai.client/src/app/services/tool/tool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ export class ToolService {
return this.enabledTools().length;
});

/**
* The tools the picker should render. Agent-locked → only the bound tools
* (the agent dictates a fixed set, so hide the rest rather than show a long
* greyed list); otherwise every accessible tool.
*/
readonly visibleTools = computed(() => {
const locked = this._agentLockedToolIds();
if (locked !== null) {
return this._tools().filter(t => locked.includes(t.toolId));
}
return this._tools();
});

/**
* Whether a tool row should render as ON. Agent-locked → membership in the
* bound set (so greyed toggles honestly show the Agent's toolset, not the
Expand Down
9 changes: 7 additions & 2 deletions frontend/ai.client/src/app/session/session.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,18 @@ export class ConversationPage implements OnDestroy {
return;
}

// No assistant in the URL — clear any stale state from a prior load.
// No assistant in the URL — clear any stale local state from a prior load.
if (loadedAssistant || this.assistantError() || this.agent()) {
this.assistant.set(null);
this.assistantError.set(null);
this.agent.set(null);
this.clearAgentBindingLocks();
}
// Always release the picker locks. They live in root singleton services
// that OUTLIVE this component, so a freshly-created "new chat" component
// (whose own assistant()/agent() signals start null, making the guard
// above false) must still release locks the previous conversation left
// behind. Idempotent — a no-op when nothing is locked.
this.clearAgentBindingLocks();
});

// Self-heal effect: when the user lands on `/s/:id` without an
Expand Down