diff --git a/frontend/ai.client/src/app/services/skill/skill.service.spec.ts b/frontend/ai.client/src/app/services/skill/skill.service.spec.ts
index 9464dbed..bd2acf43 100644
--- a/frontend/ai.client/src/app/services/skill/skill.service.spec.ts
+++ b/frontend/ai.client/src/app/services/skill/skill.service.spec.ts
@@ -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);
});
});
diff --git a/frontend/ai.client/src/app/services/skill/skill.service.ts b/frontend/ai.client/src/app/services/skill/skill.service.ts
index e5118b7f..39e7f540 100644
--- a/frontend/ai.client/src/app/services/skill/skill.service.ts
+++ b/frontend/ai.client/src/app/services/skill/skill.service.ts
@@ -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.
diff --git a/frontend/ai.client/src/app/services/tool/tool.service.spec.ts b/frontend/ai.client/src/app/services/tool/tool.service.spec.ts
index 51ee29ab..611b6898 100644
--- a/frontend/ai.client/src/app/services/tool/tool.service.spec.ts
+++ b/frontend/ai.client/src/app/services/tool/tool.service.spec.ts
@@ -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)', () => {
diff --git a/frontend/ai.client/src/app/services/tool/tool.service.ts b/frontend/ai.client/src/app/services/tool/tool.service.ts
index f36aec61..fc0b5597 100644
--- a/frontend/ai.client/src/app/services/tool/tool.service.ts
+++ b/frontend/ai.client/src/app/services/tool/tool.service.ts
@@ -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
diff --git a/frontend/ai.client/src/app/session/session.page.ts b/frontend/ai.client/src/app/session/session.page.ts
index 892d3ff6..0d4fabbb 100644
--- a/frontend/ai.client/src/app/session/session.page.ts
+++ b/frontend/ai.client/src/app/session/session.page.ts
@@ -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