From b87be1300b778f907a08d67b6cefd4087341752f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 02:02:57 +0000 Subject: [PATCH 1/5] feat(skills): disable skills from PromptComposer and terminal AI Input Add a shared composer skills control that toggles manageable skills using the same .atmos/skills/.disabled move as the Skills page for projects, and workplace-rooted disable for workspaces (with symlink ancestor materialization so sync-dir copies/symlinks stay local). Tip users that changes apply on new Agent sessions only. Co-authored-by: AruNi_Lu --- apps/api/src/api/ws/message.rs | 2 + apps/api/src/api/ws/message/skills.rs | 19 ++ apps/api/src/api/ws/router/mod.rs | 4 + apps/api/src/api/ws/router/skills.rs | 38 ++- apps/web/messages/en.json | 22 ++ apps/web/messages/zh.json | 22 ++ apps/web/src/api/ws/skills-api.ts | 20 +- .../connection/hooks/use-websocket.ts | 1 + .../components/ComposerSkillsControl.tsx | 261 ++++++++++++++++++ .../components/InstalledSkillListCard.tsx | 6 + .../skills/components/SkillDetail.tsx | 6 + .../components/TerminalAgentInputOverlay.tsx | 41 ++- .../components/TerminalSideChatModal.tsx | 21 ++ .../terminal-mosaic-scoped-pane-window.tsx | 19 ++ .../terminal-mosaic-workspace-pane-window.tsx | 19 ++ .../components/SlashCommandPopover.tsx | 6 +- .../components/WelcomeComposerCard.tsx | 3 + .../components/WelcomeComposerControls.tsx | 3 + .../welcome/components/WelcomePage.tsx | 13 + crates/core-service/src/service/skill.rs | 95 +++++-- .../core-service/src/service/skill/scanner.rs | 55 +++- .../core-service/src/service/skill/support.rs | 140 +++++++++- .../core-service/src/service/skill/tests.rs | 132 +++++++++ .../BRAINSTORM.md | 53 ++++ .../APP-040_composer-skills-disable/PRD.md | 47 ++++ .../APP-040_composer-skills-disable/TECH.md | 71 +++++ .../APP-040_composer-skills-disable/TEST.md | 80 ++++++ specs/README.md | 1 + 28 files changed, 1161 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/features/skills/components/ComposerSkillsControl.tsx create mode 100644 specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md create mode 100644 specs/APP/APP-040_composer-skills-disable/PRD.md create mode 100644 specs/APP/APP-040_composer-skills-disable/TECH.md create mode 100644 specs/APP/APP-040_composer-skills-disable/TEST.md diff --git a/apps/api/src/api/ws/message.rs b/apps/api/src/api/ws/message.rs index 629de9e52..8c6c36700 100644 --- a/apps/api/src/api/ws/message.rs +++ b/apps/api/src/api/ws/message.rs @@ -433,6 +433,8 @@ pub enum WsAction { SkillsGet, /// Enable or disable a managed skill across all placements SkillsSetEnabled, + /// Scan skills under a single project/workspace root (composer freshness) + SkillsScanRoot, /// Delete a managed skill across all placements SkillsDelete, /// 安装 Project Wiki skill 到 ~/.atmos/skills/.system/project-wiki diff --git a/apps/api/src/api/ws/message/skills.rs b/apps/api/src/api/ws/message/skills.rs index d5a692822..323e3d459 100644 --- a/apps/api/src/api/ws/message/skills.rs +++ b/apps/api/src/api/ws/message/skills.rs @@ -24,11 +24,22 @@ pub struct SkillsGetRequest { pub id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillsScopeRootDto { + pub scope: String, + pub id: String, + pub name: String, + pub path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillsSetEnabledRequest { pub id: String, pub enabled: bool, pub placement_ids: Option>, + /// Optional extra manageable root (e.g. workspace worktree) for composer toggles. + #[serde(default)] + pub scope_root: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -36,3 +47,11 @@ pub struct SkillsDeleteRequest { pub id: String, pub placement_ids: Option>, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillsScanRootRequest { + pub scope: String, + pub id: String, + pub name: String, + pub path: String, +} diff --git a/apps/api/src/api/ws/router/mod.rs b/apps/api/src/api/ws/router/mod.rs index 1afcef499..529017877 100644 --- a/apps/api/src/api/ws/router/mod.rs +++ b/apps/api/src/api/ws/router/mod.rs @@ -537,6 +537,10 @@ impl WsMessageService { self.handle_skills_set_enabled(parse_request(request.data)?) .await } + WsAction::SkillsScanRoot => { + self.handle_skills_scan_root(parse_request(request.data)?) + .await + } WsAction::SkillsDelete => { self.handle_skills_delete(parse_request(request.data)?) .await diff --git a/apps/api/src/api/ws/router/skills.rs b/apps/api/src/api/ws/router/skills.rs index 70f80c7b5..a87cd808d 100644 --- a/apps/api/src/api/ws/router/skills.rs +++ b/apps/api/src/api/ws/router/skills.rs @@ -122,7 +122,7 @@ impl WsMessageService { &self, req: SkillsSetEnabledRequest, ) -> Result { - use core_service::service::skill::SkillManager; + use core_service::service::skill::{SkillManager, SkillScopeRoot}; let projects = self.project_service.list_projects().await?; let project_paths: Vec<(String, String, String)> = projects @@ -130,8 +130,20 @@ impl WsMessageService { .map(|p| (p.guid.clone(), p.name.clone(), p.main_file_path.clone())) .collect(); - SkillManager::set_enabled( + let extra_roots: Vec = req + .scope_root + .into_iter() + .map(|root| SkillScopeRoot { + scope: root.scope, + id: root.id, + name: root.name, + path: root.path, + }) + .collect(); + + SkillManager::set_enabled_with_extra_roots( &project_paths, + &extra_roots, &req.id, req.enabled, req.placement_ids.as_deref(), @@ -141,6 +153,28 @@ impl WsMessageService { Ok(json!({ "success": true })) } + pub(super) async fn handle_skills_scan_root( + &self, + req: SkillsScanRootRequest, + ) -> Result { + use core_service::service::skill::{ScanMode, SkillScanner, SkillScopeRoot}; + + let root = SkillScopeRoot { + scope: req.scope, + id: req.id, + name: req.name, + path: req.path, + }; + + let skills = tokio::task::spawn_blocking(move || { + SkillScanner::scan_root(&root, ScanMode::Lazy) + }) + .await + .map_err(|e| ServiceError::Processing(format!("skills scan_root join error: {e}")))?; + + Ok(json!({ "skills": skills })) + } + pub(super) async fn handle_skills_delete(&self, req: SkillsDeleteRequest) -> Result { use core_service::service::skill::SkillManager; diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 4b3fa9d25..a11eaff40 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3100,6 +3100,7 @@ "loadingSkills": "Loading skills...", "global": "Global", "project": "Project", + "workspace": "Workspace", "showMore": "Show {count} more", "noSkillsAvailable": "No skills available", "projects": "Projects", @@ -6708,6 +6709,26 @@ }, "marketplaceFrameTitle": "Skills marketplace" }, + "composerDisable": { + "triggerLabel": "Manage skills", + "triggerShort": "Skills", + "title": "Skills", + "sessionTip": "Disabling a skill takes effect in a new Agent session. Skills already loaded into the current session’s system prompt stay available until you start a new session.", + "loading": "Loading skills...", + "empty": "No manageable skills in this context.", + "scope": { + "global": "Global", + "project": "Project", + "workspace": "Workspace" + }, + "enableSkill": "Enable {name}", + "disableSkill": "Disable {name}", + "toasts": { + "enableFailed": "Failed to enable skill", + "disableFailed": "Failed to disable skill", + "tryAgain": "Please try again." + } + }, "installedTab": { "empty": { "noneInstalledTitle": "No skills installed", @@ -6720,6 +6741,7 @@ "scope": { "global": "Global", "project": "Project", + "workspace": "Workspace", "system": "System", "insideProject": "Inside Project" }, diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 3944a459f..5dcd4e981 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -3100,6 +3100,7 @@ "loadingSkills": "正在加载技能...", "global": "全局", "project": "项目", + "workspace": "工作区", "showMore": "再显示 {count} 个", "noSkillsAvailable": "没有可用技能", "projects": "项目", @@ -6708,6 +6709,26 @@ }, "marketplaceFrameTitle": "技能市场" }, + "composerDisable": { + "triggerLabel": "管理技能", + "triggerShort": "技能", + "title": "技能", + "sessionTip": "禁用技能后,需要新的 Agent session 才会生效。当前已初始化的 session 里,技能已经加载进系统提示词,仍会保持可用,直到你开启新的 session。", + "loading": "正在加载技能…", + "empty": "当前上下文没有可管理的技能。", + "scope": { + "global": "全局", + "project": "项目", + "workspace": "工作区" + }, + "enableSkill": "启用 {name}", + "disableSkill": "禁用 {name}", + "toasts": { + "enableFailed": "启用技能失败", + "disableFailed": "禁用技能失败", + "tryAgain": "请重试。" + } + }, "installedTab": { "empty": { "noneInstalledTitle": "没有已安装技能", @@ -6720,6 +6741,7 @@ "scope": { "global": "全局", "project": "项目", + "workspace": "工作区", "system": "系统", "insideProject": "项目内" }, diff --git a/apps/web/src/api/ws/skills-api.ts b/apps/web/src/api/ws/skills-api.ts index f34adf3dd..92f341dc6 100644 --- a/apps/web/src/api/ws/skills-api.ts +++ b/apps/web/src/api/ws/skills-api.ts @@ -15,7 +15,7 @@ export interface SkillFile { export interface SkillPlacement { id: string; agent: string; - scope: "global" | "project" | "inside_project" | "system"; + scope: "global" | "project" | "workspace" | "inside_project" | "system"; project_id: string | null; project_name: string | null; path: string; @@ -33,7 +33,7 @@ export interface SkillInfo { name: string; description: string; agents: string[]; - scope: "global" | "project" | "inside_project" | "system"; + scope: "global" | "project" | "workspace" | "inside_project" | "system"; project_id: string | null; project_name: string | null; path: string; @@ -46,6 +46,13 @@ export interface SkillInfo { placements: SkillPlacement[]; } +export type SkillScopeRoot = { + scope: "project" | "workspace"; + id: string; + name: string; + path: string; +}; + export const skillsApi = { /** * 获取已安装的 Skills 列表 @@ -56,6 +63,13 @@ export const skillsApi = { }); }, + /** + * Scan a single project/workspace root for composer toggles (no disk cache). + */ + scanRoot: async (root: SkillScopeRoot): Promise<{ skills: SkillInfo[] }> => { + return wsRequest<{ skills: SkillInfo[] }>("skills_scan_root", root); + }, + /** * 获取单个 Skill 详情 */ @@ -67,11 +81,13 @@ export const skillsApi = { id: string, enabled: boolean, placementIds?: string[], + scopeRoot?: SkillScopeRoot, ): Promise<{ success: boolean }> => { return wsRequest<{ success: boolean }>("skills_set_enabled", { id, enabled, placement_ids: placementIds, + scope_root: scopeRoot, }); }, diff --git a/apps/web/src/features/connection/hooks/use-websocket.ts b/apps/web/src/features/connection/hooks/use-websocket.ts index f026b11ff..9c8d5edf1 100644 --- a/apps/web/src/features/connection/hooks/use-websocket.ts +++ b/apps/web/src/features/connection/hooks/use-websocket.ts @@ -139,6 +139,7 @@ export type WsAction = | "skills_list" | "skills_get" | "skills_set_enabled" + | "skills_scan_root" | "skills_delete" | "wiki_skill_install" | "wiki_skill_system_status" diff --git a/apps/web/src/features/skills/components/ComposerSkillsControl.tsx b/apps/web/src/features/skills/components/ComposerSkillsControl.tsx new file mode 100644 index 000000000..81af3a077 --- /dev/null +++ b/apps/web/src/features/skills/components/ComposerSkillsControl.tsx @@ -0,0 +1,261 @@ +"use client"; + +import React from "react"; +import { useTranslations } from "next-intl"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + Switch, + cn, + toastManager, +} from "@workspace/ui"; +import { Loader2, Puzzle } from "lucide-react"; +import { + forceRefreshSkillsList, + useInvalidateSkillsList, +} from "@/features/skills/hooks/use-skills-query"; +import { + skillsApi, + type SkillInfo, + type SkillScopeRoot, +} from "@/api/ws/skills-api"; + +export type ComposerSkillsContext = { + mode: "project" | "workspace"; + id: string; + name: string; + path: string; +}; + +function sortSkills(skills: SkillInfo[]) { + return [...skills].sort((a, b) => { + if (a.status !== b.status) { + if (a.status === "enabled") return -1; + if (b.status === "enabled") return 1; + } + return a.name.localeCompare(b.name); + }); +} + +function scopeLabel( + skill: SkillInfo, + t: ReturnType, +) { + switch (skill.scope) { + case "global": + return t("scope.global"); + case "workspace": + return t("scope.workspace"); + case "project": + return t("scope.project"); + default: + return skill.scope; + } +} + +export function ComposerSkillsControl({ + context, + align = "end", + className, + triggerClassName, +}: { + context: ComposerSkillsContext | null; + align?: "start" | "center" | "end"; + className?: string; + triggerClassName?: string; +}) { + const t = useTranslations("skills.composerDisable"); + const invalidateSkillsList = useInvalidateSkillsList(); + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [skills, setSkills] = React.useState([]); + const [pendingId, setPendingId] = React.useState(null); + + const scopeRoot = React.useMemo(() => { + if (!context) return undefined; + return { + scope: context.mode, + id: context.id, + name: context.name, + path: context.path, + }; + }, [context]); + + const loadSkills = React.useCallback(async () => { + if (!context) { + setSkills([]); + return; + } + setLoading(true); + try { + if (context.mode === "workspace") { + const [rootResult, listResult] = await Promise.all([ + skillsApi.scanRoot({ + scope: "workspace", + id: context.id, + name: context.name, + path: context.path, + }), + skillsApi.list({ forceRefresh: true }), + ]); + const workplace = rootResult.skills.filter((skill) => skill.can_toggle); + const globals = listResult.skills.filter( + (skill) => skill.can_toggle && skill.scope === "global", + ); + const byId = new Map(); + for (const skill of [...workplace, ...globals]) { + byId.set(skill.id, skill); + } + setSkills(sortSkills([...byId.values()])); + } else { + const listResult = await skillsApi.list({ forceRefresh: true }); + setSkills( + sortSkills( + listResult.skills.filter( + (skill) => + skill.can_toggle && + (skill.scope === "global" || + (skill.scope === "project" && skill.project_id === context.id)), + ), + ), + ); + } + } catch (error) { + console.error("Failed to load composer skills:", error); + setSkills([]); + } finally { + setLoading(false); + } + }, [context]); + + React.useEffect(() => { + if (!open) return; + void loadSkills(); + }, [loadSkills, open]); + + const handleToggle = React.useCallback( + async (skill: SkillInfo, enabled: boolean) => { + setPendingId(skill.id); + try { + const root = + skill.scope === "workspace" || + (context?.mode === "workspace" && skill.scope !== "global") + ? scopeRoot + : undefined; + await skillsApi.setEnabled(skill.id, enabled, undefined, root); + setSkills((current) => + sortSkills( + current.map((item) => + item.id === skill.id + ? { + ...item, + status: enabled ? "enabled" : "disabled", + placements: item.placements.map((placement) => ({ + ...placement, + status: enabled ? "enabled" : "disabled", + })), + } + : item, + ), + ), + ); + invalidateSkillsList(); + if (skill.scope !== "workspace") { + void forceRefreshSkillsList().catch(() => {}); + } + } catch (error) { + toastManager.add({ + title: enabled ? t("toasts.enableFailed") : t("toasts.disableFailed"), + description: error instanceof Error ? error.message : t("toasts.tryAgain"), + type: "error", + }); + void loadSkills(); + } finally { + setPendingId(null); + } + }, + [context?.mode, invalidateSkillsList, loadSkills, scopeRoot, t], + ); + + if (!context?.path) { + return null; + } + + return ( +
+ + + + + +
+

{t("title")}

+

{t("sessionTip")}

+
+
+ {loading ? ( +
+ + {t("loading")} +
+ ) : skills.length === 0 ? ( +
{t("empty")}
+ ) : ( + skills.map((skill) => { + const enabled = skill.status !== "disabled"; + const busy = pendingId === skill.id; + return ( +
+
+

+ {skill.title || skill.name} +

+

+ {scopeLabel(skill, t)} +

+
+ { + void handleToggle(skill, checked); + }} + aria-label={ + enabled + ? t("disableSkill", { name: skill.name }) + : t("enableSkill", { name: skill.name }) + } + /> +
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/features/skills/components/InstalledSkillListCard.tsx b/apps/web/src/features/skills/components/InstalledSkillListCard.tsx index 4765ef6a8..2ae5a6211 100644 --- a/apps/web/src/features/skills/components/InstalledSkillListCard.tsx +++ b/apps/web/src/features/skills/components/InstalledSkillListCard.tsx @@ -40,6 +40,12 @@ function getScopeMeta(scope: SkillInfo["scope"]) { icon: Folder, className: "bg-muted text-foreground", }; + case "workspace": + return { + label: "Workspace", + icon: Folder, + className: "bg-muted text-foreground", + }; case "system": return { label: "Atmos Built-in", diff --git a/apps/web/src/features/skills/components/SkillDetail.tsx b/apps/web/src/features/skills/components/SkillDetail.tsx index ac5fe91aa..4ff65851e 100644 --- a/apps/web/src/features/skills/components/SkillDetail.tsx +++ b/apps/web/src/features/skills/components/SkillDetail.tsx @@ -143,6 +143,12 @@ function getScopeMeta( icon: Folder, className: 'bg-muted text-foreground', }; + case 'workspace': + return { + label: t('scope.workspace'), + icon: Folder, + className: 'bg-muted text-foreground', + }; case 'system': return { label: t('scope.system'), diff --git a/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx b/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx index ec28f7271..46a010dd6 100644 --- a/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx +++ b/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx @@ -17,6 +17,10 @@ import { type SlashTriggerContext, } from "@/features/welcome/components/PromptComposer"; import { WelcomeAgentSelector } from "@/features/welcome/components/WelcomeComposerControls"; +import { + ComposerSkillsControl, + type ComposerSkillsContext, +} from "@/features/skills/components/ComposerSkillsControl"; import type { AgentMenuOption } from "@/features/welcome/lib/welcome-page-helpers"; import { type MentionNavItem, @@ -93,6 +97,7 @@ interface TerminalAgentInputOverlayProps { sideChatAgent?: TerminalPaneAgent | null; sideChatAgentOptions?: TerminalPaneAgent[]; sideChatDots?: React.ReactNode; + skillsContext?: ComposerSkillsContext | null; submitMode?: TerminalAgentSubmitMode; } @@ -123,6 +128,7 @@ export const TerminalAgentInputOverlay = React.forwardRef< sideChatAgent, sideChatAgentOptions = [], sideChatDots, + skillsContext = null, submitMode = "text-enter", }, ref) { const t = useTranslations("terminal.agentInput"); @@ -811,21 +817,26 @@ export const TerminalAgentInputOverlay = React.forwardRef< placeholder={t("placeholder")} startSendExit={startSendExit} footerEndControl={ - shouldShowSideChatAgentSelector ? ( -
- + skillsContext || shouldShowSideChatAgentSelector ? ( +
+ {skillsContext ? : null} + {shouldShowSideChatAgentSelector ? ( +
+ +
+ ) : null}
) : undefined } diff --git a/apps/web/src/features/terminal/components/TerminalSideChatModal.tsx b/apps/web/src/features/terminal/components/TerminalSideChatModal.tsx index 2d8a6e5d4..3117976bf 100644 --- a/apps/web/src/features/terminal/components/TerminalSideChatModal.tsx +++ b/apps/web/src/features/terminal/components/TerminalSideChatModal.tsx @@ -78,6 +78,26 @@ export function TerminalSideChatModal({ const overlayRef = React.useRef(null); const agentInputOverlayRefs = React.useRef>(new Map()); const [closeAllConfirmOpen, setCloseAllConfirmOpen] = React.useState(false); + const skillsContext = React.useMemo(() => { + if (!localPath) return null; + const isProjectRoot = + !!projectRootPath && + (localPath === projectRootPath || localPath.replace(/\/$/, "") === projectRootPath.replace(/\/$/, "")); + if (isProjectRoot) { + return { + mode: "project" as const, + id: workspaceId, + name: projectName || workspaceId, + path: projectRootPath || localPath, + }; + } + return { + mode: "workspace" as const, + id: workspaceId, + name: workspaceName || projectName || workspaceId, + path: localPath, + }; + }, [localPath, projectName, projectRootPath, workspaceId, workspaceName]); const [readySideChatIds, setReadySideChatIds] = React.useState>(() => new Set()); const [isFocusedWithin, setIsFocusedWithin] = React.useState(true); const modalRef = React.useRef(null); @@ -350,6 +370,7 @@ export function TerminalSideChatModal({ agent={record.agent} isTerminalReady={readySideChatIds.has(record.side_chat_id)} localPath={localPath} + skillsContext={skillsContext} onHide={() => { terminalRefs.current.get(record.side_chat_id)?.focus(); }} diff --git a/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx b/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx index 26a321b3a..c107950a7 100644 --- a/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx +++ b/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx @@ -153,6 +153,24 @@ export function TerminalMosaicScopedPaneWindow({ const isPanePinned = panePinKey ? pinnedPaneKeys.has(panePinKey) : false; const agentForSubmit = pane.agent ?? toolbarAgent; const agentSubmitMode = resolveTerminalAgentSubmitMode(agentForSubmit); + const skillsContext = React.useMemo(() => { + if (!workspaceInfo?.localPath) return null; + if (isProjectContext) { + if (!activeProject) return null; + return { + mode: "project" as const, + id: activeProject.id, + name: activeProject.name, + path: activeProject.mainFilePath || workspaceInfo.localPath, + }; + } + return { + mode: "workspace" as const, + id: workspaceId, + name: workspaceInfo.workspaceName || workspaceInfo.projectName || workspaceId, + path: workspaceInfo.localPath, + }; + }, [activeProject, isProjectContext, workspaceId, workspaceInfo]); const sideChatAgentOptions = React.useMemo(() => { const options = quickOpenAgents.map(({ agent, command }) => ({ ...agent, command })); if (agentForSubmit?.command?.trim() && !options.some((agent) => agent.id === agentForSubmit.id)) { @@ -423,6 +441,7 @@ export function TerminalMosaicScopedPaneWindow({ getSideChatFlyTargetClientPoint={getSideChatFlyTargetClientPoint} isTerminalReady={isTerminalReady} localPath={workspaceInfo?.localPath} + skillsContext={skillsContext} onHide={() => { terminalRefsMap.current.get(id)?.focus(); }} diff --git a/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx b/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx index 25ff73462..fc21e9776 100644 --- a/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx +++ b/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx @@ -182,6 +182,24 @@ export function TerminalMosaicWorkspacePaneWindow(props: TerminalMosaicWorkspace const isPanePinned = panePinKey ? pinnedPaneKeys.has(panePinKey) : false; const agentForSubmit = pane.agent ?? toolbarAgent; const agentSubmitMode = resolveTerminalAgentSubmitMode(agentForSubmit); + const skillsContext = useMemo(() => { + if (!workspaceInfo?.localPath) return null; + if (isProjectContext) { + if (!activeProject) return null; + return { + mode: "project" as const, + id: activeProject.id, + name: activeProject.name, + path: activeProject.mainFilePath || workspaceInfo.localPath, + }; + } + return { + mode: "workspace" as const, + id: workspaceId, + name: workspaceInfo.workspaceName || workspaceInfo.projectName || workspaceId, + path: workspaceInfo.localPath, + }; + }, [activeProject, isProjectContext, workspaceId, workspaceInfo]); const sideChatAgentOptions = useMemo(() => { const options = quickOpenAgents.map(({ agent, command }) => ({ ...agent, command })); if (agentForSubmit?.command?.trim() && !options.some((agent) => agent.id === agentForSubmit.id)) { @@ -484,6 +502,7 @@ export function TerminalMosaicWorkspacePaneWindow(props: TerminalMosaicWorkspace getSideChatFlyTargetClientPoint={getSideChatFlyTargetClientPoint} isTerminalReady={isTerminalReady} localPath={workspaceInfo?.localPath} + skillsContext={skillsContext} onHide={() => { terminalRefsMap.current.get(id)?.focus(); }} diff --git a/apps/web/src/features/welcome/components/SlashCommandPopover.tsx b/apps/web/src/features/welcome/components/SlashCommandPopover.tsx index 2108bb051..7e4ba6582 100644 --- a/apps/web/src/features/welcome/components/SlashCommandPopover.tsx +++ b/apps/web/src/features/welcome/components/SlashCommandPopover.tsx @@ -190,7 +190,11 @@ export function SlashCommandPopover({ {skill.name} - {skill.scope === "global" ? t("slashPopover.global") : t("slashPopover.project")} + {skill.scope === "global" + ? t("slashPopover.global") + : skill.scope === "workspace" + ? t("slashPopover.workspace") + : t("slashPopover.project")} ))} diff --git a/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx b/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx index 016c5b898..a5d4812e5 100644 --- a/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx +++ b/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx @@ -44,6 +44,7 @@ export function WelcomeComposerCard({ setPriority, setSelectedLabels, setWorkflowStatus, + skillsControl, workflowStatus, workspaceLabels, controls, @@ -74,6 +75,7 @@ export function WelcomeComposerCard({ setPriority?: (value: WorkspacePriority) => void; setSelectedLabels?: (labels: WorkspaceLabel[]) => void; setWorkflowStatus?: (value: WorkspaceWorkflowStatus) => void; + skillsControl?: React.ReactNode; workflowStatus?: WorkspaceWorkflowStatus; workspaceLabels?: WorkspaceLabel[]; controls?: React.ReactNode; @@ -123,6 +125,7 @@ export function WelcomeComposerCard({ setPriority={setPriority!} setSelectedLabels={setSelectedLabels!} setWorkflowStatus={setWorkflowStatus!} + skillsControl={skillsControl} workflowStatus={workflowStatus!} workspaceLabels={workspaceLabels!} /> diff --git a/apps/web/src/features/welcome/components/WelcomeComposerControls.tsx b/apps/web/src/features/welcome/components/WelcomeComposerControls.tsx index 99b1b3b77..6417c614f 100644 --- a/apps/web/src/features/welcome/components/WelcomeComposerControls.tsx +++ b/apps/web/src/features/welcome/components/WelcomeComposerControls.tsx @@ -123,6 +123,7 @@ export function WelcomeComposerControls({ setPriority, setSelectedLabels, setWorkflowStatus, + skillsControl, workflowStatus, workspaceLabels, }: { @@ -140,6 +141,7 @@ export function WelcomeComposerControls({ setPriority: (value: WorkspacePriority) => void; setSelectedLabels: (labels: WorkspaceLabel[]) => void; setWorkflowStatus: (value: WorkspaceWorkflowStatus) => void; + skillsControl?: React.ReactNode; workflowStatus: WorkspaceWorkflowStatus; workspaceLabels: WorkspaceLabel[]; }) { @@ -226,6 +228,7 @@ export function WelcomeComposerControls({ />
+ {skillsControl} diff --git a/apps/web/src/features/welcome/components/WelcomePage.tsx b/apps/web/src/features/welcome/components/WelcomePage.tsx index aa92c6b6a..1a95b2074 100644 --- a/apps/web/src/features/welcome/components/WelcomePage.tsx +++ b/apps/web/src/features/welcome/components/WelcomePage.tsx @@ -44,6 +44,7 @@ import type { import { WelcomeAgentSelector } from "@/features/welcome/components/WelcomeComposerControls"; import { WelcomeComposerCard } from "@/features/welcome/components/WelcomeComposerCard"; import { WelcomeComposerFooter } from "@/features/welcome/components/WelcomeComposerFooter"; +import { ComposerSkillsControl } from "@/features/skills/components/ComposerSkillsControl"; import { WelcomeCloseButton, WelcomeComposerPlaceholder, @@ -852,6 +853,18 @@ const WelcomePage: React.FC = ({ setPriority={setPriority} setSelectedLabels={setSelectedLabels} setWorkflowStatus={setWorkflowStatus} + skillsControl={ + selectedProject?.mainFilePath ? ( + + ) : null + } workflowStatus={workflowStatus} workspaceLabels={workspaceLabels} footer={composerFooter} diff --git a/crates/core-service/src/service/skill.rs b/crates/core-service/src/service/skill.rs index b030795d4..163d1b5c3 100644 --- a/crates/core-service/src/service/skill.rs +++ b/crates/core-service/src/service/skill.rs @@ -10,7 +10,7 @@ mod support; mod types; use self::support::{ - delete_entry_without_following_symlink, ensure_selection_applied, + delete_entry_without_following_symlink, ensure_entry_local_to_root, ensure_selection_applied, move_entry_without_following_symlink, placement_matches_selection, project_records, selected_placement_ids, ProjectPathRecord, }; @@ -26,6 +26,15 @@ pub struct SkillScanner; pub struct SkillManager; +/// Extra manageable root (project or workspace) supplied by the caller. +#[derive(Debug, Clone)] +pub struct SkillScopeRoot { + pub scope: String, + pub id: String, + pub name: String, + pub path: String, +} + impl SkillManager { pub fn set_enabled( project_paths: &[(String, String, String)], @@ -33,8 +42,18 @@ impl SkillManager { enabled: bool, placement_ids: Option<&[String]>, ) -> Result<()> { - let project_records = project_records(project_paths); - let skill = Self::load_managed_skill(project_paths, skill_id)?; + Self::set_enabled_with_extra_roots(project_paths, &[], skill_id, enabled, placement_ids) + } + + pub fn set_enabled_with_extra_roots( + project_paths: &[(String, String, String)], + extra_roots: &[SkillScopeRoot], + skill_id: &str, + enabled: bool, + placement_ids: Option<&[String]>, + ) -> Result<()> { + let project_records = Self::merged_root_records(project_paths, extra_roots); + let skill = Self::load_managed_skill_with_extra_roots(project_paths, extra_roots, skill_id)?; let desired_status = if enabled { "enabled" } else { "disabled" }; let selected_placement_ids = selected_placement_ids(placement_ids)?; @@ -55,6 +74,12 @@ impl SkillManager { Self::disabled_path_for(&project_records, placement)? }; + if placement.scope == "workspace" { + let scope_root = Self::scope_root_for(&project_records, placement)?; + // Localize the path that currently exists on disk before moving. + ensure_entry_local_to_root(&scope_root, &from)?; + } + move_entry_without_following_symlink(&from, &to)?; } @@ -94,7 +119,15 @@ impl SkillManager { project_paths: &[(String, String, String)], skill_id: &str, ) -> Result { - let skill = SkillScanner::scan_all(project_paths) + Self::load_managed_skill_with_extra_roots(project_paths, &[], skill_id) + } + + fn load_managed_skill_with_extra_roots( + project_paths: &[(String, String, String)], + extra_roots: &[SkillScopeRoot], + skill_id: &str, + ) -> Result { + let skill = SkillScanner::scan_all_with_extra_roots(project_paths, extra_roots) .into_iter() .find(|skill| skill.id == skill_id) .ok_or_else(|| ServiceError::Validation("Skill not found".to_string()))?; @@ -108,28 +141,56 @@ impl SkillManager { Ok(skill) } - fn disabled_path_for( + fn merged_root_records( + project_paths: &[(String, String, String)], + extra_roots: &[SkillScopeRoot], + ) -> Vec { + let mut records = project_records(project_paths); + for root in extra_roots { + if root.scope != "project" && root.scope != "workspace" { + continue; + } + if records + .iter() + .any(|record| record.project_id == root.id && record.root_path == PathBuf::from(&root.path)) + { + continue; + } + records.push(ProjectPathRecord { + project_id: root.id.clone(), + root_path: PathBuf::from(&root.path), + }); + } + records + } + + fn scope_root_for( project_records: &[ProjectPathRecord], placement: &SkillPlacement, ) -> Result { - let original_path = PathBuf::from(&placement.original_path); - let scope_root = match placement.scope.as_str() { + match placement.scope.as_str() { "global" => dirs::home_dir().ok_or_else(|| { ServiceError::Validation("Cannot determine home directory".to_string()) - })?, - "project" => project_records + }), + "project" | "workspace" => project_records .iter() .find(|record| Some(record.project_id.as_str()) == placement.project_id.as_deref()) .map(|record| record.root_path.clone()) .ok_or_else(|| { - ServiceError::Validation("Project root not found for skill".to_string()) - })?, - _ => { - return Err(ServiceError::Validation( - "This skill cannot be disabled".to_string(), - )); - } - }; + ServiceError::Validation("Managed root not found for skill".to_string()) + }), + _ => Err(ServiceError::Validation( + "This skill cannot be disabled".to_string(), + )), + } + } + + fn disabled_path_for( + project_records: &[ProjectPathRecord], + placement: &SkillPlacement, + ) -> Result { + let original_path = PathBuf::from(&placement.original_path); + let scope_root = Self::scope_root_for(project_records, placement)?; let relative = original_path.strip_prefix(&scope_root).map_err(|_| { ServiceError::Validation("Skill path is outside of its managed root".to_string()) diff --git a/crates/core-service/src/service/skill/scanner.rs b/crates/core-service/src/service/skill/scanner.rs index 408e6fa58..80dc06157 100644 --- a/crates/core-service/src/service/skill/scanner.rs +++ b/crates/core-service/src/service/skill/scanner.rs @@ -70,6 +70,22 @@ impl SkillScanner { pub fn scan_all_with_mode( project_paths: &[(String, String, String)], mode: ScanMode, + ) -> Vec { + Self::scan_all_with_extra_roots_mode(project_paths, &[], mode) + } + + /// Scan global/system/projects plus optional extra manageable roots (e.g. workspace). + pub fn scan_all_with_extra_roots( + project_paths: &[(String, String, String)], + extra_roots: &[super::SkillScopeRoot], + ) -> Vec { + Self::scan_all_with_extra_roots_mode(project_paths, extra_roots, ScanMode::Full) + } + + pub fn scan_all_with_extra_roots_mode( + project_paths: &[(String, String, String)], + extra_roots: &[super::SkillScopeRoot], + mode: ScanMode, ) -> Vec { let mut raw_skills = Vec::new(); @@ -91,9 +107,46 @@ impl SkillScanner { } } + for root in extra_roots { + let path = Path::new(&root.path); + if !path.exists() { + continue; + } + let scope = root.scope.as_str(); + if scope != "project" && scope != "workspace" { + continue; + } + raw_skills.extend(Self::scan_scope( + path, + scope, + Some(root.id.clone()), + Some(root.name.clone()), + mode, + )); + } + Self::merge_skills(raw_skills) } + /// Scan a single manageable root (project or workspace) without global/system noise. + pub fn scan_root(root: &super::SkillScopeRoot, mode: ScanMode) -> Vec { + let path = Path::new(&root.path); + if !path.exists() { + return Vec::new(); + } + let scope = root.scope.as_str(); + if scope != "project" && scope != "workspace" { + return Vec::new(); + } + Self::merge_skills(Self::scan_scope( + path, + scope, + Some(root.id.clone()), + Some(root.name.clone()), + mode, + )) + } + /// Scan the Atmos system skills directory (`~/.atmos/skills/.system/`). /// /// Layout: @@ -449,7 +502,7 @@ impl SkillScanner { } fn normalize_scope(scope: &str, skill_dir: &str) -> String { - if scope == "project" && skill_dir == "skills" { + if (scope == "project" || scope == "workspace") && skill_dir == "skills" { "inside_project".to_string() } else { scope.to_string() diff --git a/crates/core-service/src/service/skill/support.rs b/crates/core-service/src/service/skill/support.rs index 7a99d86d5..9ad3820ba 100644 --- a/crates/core-service/src/service/skill/support.rs +++ b/crates/core-service/src/service/skill/support.rs @@ -11,7 +11,7 @@ pub(super) struct ProjectPathRecord { } pub(super) fn is_manageable_scope(scope: &str) -> bool { - matches!(scope, "global" | "project") + matches!(scope, "global" | "project" | "workspace") } pub(super) fn build_skill_id(scope: &str, project_id: Option<&str>, name: &str) -> String { @@ -137,6 +137,144 @@ pub(super) fn move_entry_without_following_symlink(from: &Path, to: &Path) -> Re } } +/// Ensure `path` can be moved without mutating trees outside `root`. +/// +/// Workspace sync dirs often materialize as a parent symlink +/// (`workplace/.claude` → `project/.claude`). Renaming through that parent +/// would move the project skill. This expands each symlink ancestor under +/// `root` into a real directory of child symlinks until `path` itself is a +/// local symlink or a real local entry. +pub(super) fn ensure_entry_local_to_root(root: &Path, path: &Path) -> Result<()> { + let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let mut guard = 0usize; + + loop { + guard += 1; + if guard > 64 { + return Err(ServiceError::Validation( + "Failed to localize skill path for workspace disable".to_string(), + )); + } + + let metadata = fs::symlink_metadata(path).map_err(|e| { + ServiceError::Validation(format!( + "Failed to inspect skill entry '{}': {}", + path.display(), + e + )) + })?; + if metadata.file_type().is_symlink() { + return Ok(()); + } + + let Some(ancestor) = find_symlink_ancestor(&root, path)? else { + return Ok(()); + }; + + materialize_symlink_dir(&ancestor)?; + } +} + +fn find_symlink_ancestor(root: &Path, path: &Path) -> Result> { + let mut current = path.parent().map(Path::to_path_buf); + let mut found = None; + + while let Some(candidate) = current { + if candidate == *root { + break; + } + if !candidate.starts_with(root) { + break; + } + + let metadata = match fs::symlink_metadata(&candidate) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => break, + Err(err) => { + return Err(ServiceError::Validation(format!( + "Failed to inspect ancestor '{}': {}", + candidate.display(), + err + ))); + } + }; + + if metadata.file_type().is_symlink() { + found = Some(candidate.clone()); + } + + current = candidate.parent().map(Path::to_path_buf); + } + + Ok(found) +} + +fn materialize_symlink_dir(link: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(link).map_err(|e| { + ServiceError::Validation(format!( + "Failed to inspect symlink '{}': {}", + link.display(), + e + )) + })?; + if !metadata.file_type().is_symlink() { + return Ok(()); + } + + let target = fs::read_link(link).map_err(|e| { + ServiceError::Validation(format!( + "Failed to read symlink '{}': {}", + link.display(), + e + )) + })?; + let resolved = if target.is_absolute() { + target + } else { + link.parent() + .unwrap_or_else(|| Path::new(".")) + .join(target) + }; + let resolved = fs::canonicalize(&resolved).unwrap_or(resolved); + + let children: Vec<_> = fs::read_dir(&resolved) + .map_err(|e| { + ServiceError::Validation(format!( + "Failed to read symlink target '{}': {}", + resolved.display(), + e + )) + })? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name()) + .collect(); + + fs::remove_file(link).map_err(|e| { + ServiceError::Validation(format!( + "Failed to replace symlink '{}': {}", + link.display(), + e + )) + })?; + fs::create_dir(link).map_err(|e| { + ServiceError::Validation(format!( + "Failed to create materialized directory '{}': {}", + link.display(), + e + )) + })?; + + for child_name in children { + let child_target = resolved.join(&child_name); + let child_link = link.join(&child_name); + let child_is_dir = child_target.is_dir(); + let absolute_target = fs::canonicalize(&child_target).unwrap_or(child_target); + create_symlink(&absolute_target, &child_link, child_is_dir)?; + } + + Ok(()) +} + fn copy_entry_without_following_symlink( from: &Path, to: &Path, diff --git a/crates/core-service/src/service/skill/tests.rs b/crates/core-service/src/service/skill/tests.rs index bf3e18dc3..b2ad948de 100644 --- a/crates/core-service/src/service/skill/tests.rs +++ b/crates/core-service/src/service/skill/tests.rs @@ -221,3 +221,135 @@ fn scan_lazy_mode_drops_non_main_file_content() { full_extra.content, ); } + +fn write_agent_skill(root: &std::path::Path, agent_skills_rel: &str, name: &str) { + let skill_dir = root.join(agent_skills_rel).join(name); + write_skill(&skill_dir, name); +} + +#[test] +fn project_disable_moves_skill_into_disabled_storage() { + use super::SkillManager; + + let tmp = tempfile::tempdir().expect("create tempdir"); + let project = tmp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + write_agent_skill(&project, ".claude/skills", "demo"); + + let project_paths = vec![( + "proj-1".to_string(), + "Demo".to_string(), + project.to_string_lossy().to_string(), + )]; + let skill_id = "project::proj-1::demo"; + + SkillManager::set_enabled(&project_paths, skill_id, false, None).expect("disable"); + + assert!(!project.join(".claude/skills/demo").exists()); + assert!(project + .join(".atmos/skills/.disabled/.claude/skills/demo/SKILL.md") + .exists()); + + let skills = SkillScanner::scan_all(&project_paths); + let skill = skills.iter().find(|s| s.id == skill_id).expect("skill"); + assert_eq!(skill.status, "disabled"); + + // Re-enable restores original path. + SkillManager::set_enabled(&project_paths, skill_id, true, None).expect("enable"); + assert!(project.join(".claude/skills/demo/SKILL.md").exists()); +} + +#[test] +fn workspace_copy_disable_is_local_to_workplace() { + use super::{SkillManager, SkillScopeRoot}; + + let tmp = tempfile::tempdir().expect("create tempdir"); + let project = tmp.path().join("project"); + let workplace = tmp.path().join("workplace"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&workplace).unwrap(); + write_agent_skill(&project, ".claude/skills", "demo"); + write_agent_skill(&workplace, ".claude/skills", "demo"); + + let root = SkillScopeRoot { + scope: "workspace".into(), + id: "ws-1".into(), + name: "Work".into(), + path: workplace.to_string_lossy().into(), + }; + let skill_id = "workspace::ws-1::demo"; + + SkillManager::set_enabled_with_extra_roots(&[], &[root.clone()], skill_id, false, None) + .expect("disable workspace copy"); + + assert!( + project.join(".claude/skills/demo/SKILL.md").exists(), + "project copy must stay" + ); + assert!(!workplace.join(".claude/skills/demo").exists()); + assert!(workplace + .join(".atmos/skills/.disabled/.claude/skills/demo/SKILL.md") + .exists()); + + let skills = SkillScanner::scan_root(&root, ScanMode::Lazy); + let skill = skills.iter().find(|s| s.id == skill_id).expect("skill"); + assert_eq!(skill.status, "disabled"); +} + +#[cfg(unix)] +#[test] +fn workspace_parent_symlink_disable_does_not_mutate_project() { + use super::{SkillManager, SkillScopeRoot}; + + let tmp = tempfile::tempdir().expect("create tempdir"); + let project = tmp.path().join("project"); + let workplace = tmp.path().join("workplace"); + fs::create_dir_all(project.join(".claude/skills")).unwrap(); + fs::create_dir_all(&workplace).unwrap(); + write_agent_skill(&project, ".claude/skills", "demo"); + write_agent_skill(&project, ".claude/skills", "keep"); + + std::os::unix::fs::symlink(project.join(".claude"), workplace.join(".claude")) + .expect("compensate parent symlink"); + + let root = SkillScopeRoot { + scope: "workspace".into(), + id: "ws-1".into(), + name: "Work".into(), + path: workplace.to_string_lossy().into(), + }; + let skill_id = "workspace::ws-1::demo"; + + SkillManager::set_enabled_with_extra_roots(&[], &[root.clone()], skill_id, false, None) + .expect("disable via materialized symlink"); + + assert!( + project.join(".claude/skills/demo/SKILL.md").exists(), + "project skill must remain after workplace-only disable" + ); + assert!( + project.join(".claude/skills/keep/SKILL.md").exists(), + "sibling project skill must remain" + ); + assert!( + !workplace.join(".claude/skills/demo").exists(), + "workplace must no longer expose enabled demo skill" + ); + assert!( + workplace + .join(".atmos/skills/.disabled/.claude/skills/demo") + .exists(), + "disabled storage should hold the moved workplace entry" + ); + // Sibling remains reachable in workplace via materialized child symlink. + assert!(workplace.join(".claude/skills/keep/SKILL.md").exists()); + + let skills = SkillScanner::scan_root(&root, ScanMode::Lazy); + let demo = skills.iter().find(|s| s.id == skill_id).expect("demo"); + assert_eq!(demo.status, "disabled"); + let keep = skills + .iter() + .find(|s| s.id == "workspace::ws-1::keep") + .expect("keep"); + assert_eq!(keep.status, "enabled"); +} diff --git a/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md new file mode 100644 index 000000000..850b75aa7 --- /dev/null +++ b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md @@ -0,0 +1,53 @@ +# BRAINSTORM · APP-040: Composer Skills Disable + +## Problem + +Users who want to temporarily hide skills from an Agent must leave the PromptComposer / +terminal AI Input, open the Skills management page, toggle there, then start a **new** +Agent session. In a workspace worktree the problem is worse: skills often arrive via +Settings → sync dirs (symlink or copy), so a project-level disable is the wrong scope +and may not match what the Agent running in that worktree actually discovers. + +## Goal (draft) + +Add a dynamic enable/disable control for skills directly in: + +1. Welcome / PromptComposer (project-oriented create flow) +2. Terminal AI Input overlay + +Behavior must match the Skills management page’s move-to-`.atmos/skills/.disabled` +mechanism for Project, and reuse that same move for Workspace with the disabled storage +rooted at the **current workplace directory**. Tip the user that an already-initialized +Agent session has already baked skills into the system prompt. + +## Options + +### Option A — Reuse SkillManager move + add workspace scope root (chosen) + +- Project: call existing `skills_set_enabled`; Skills page and composer stay in sync. +- Workspace: scan/manage skills under the workplace path; store disabled entries under + `workplace/.atmos/skills/.disabled/...`. +- Symlink sync dirs: if a parent agent dir is a compensated symlink into the project, + materialize that ancestor into per-child symlinks first so the move only affects the + workplace. Copy sync dirs: move the local copy as-is. + +**Pros**: one disable mechanism; Agents stop discovering the skill the same way. +**Cons**: materialization is a one-way local edit of the compensated tree shape. + +### Option B — Session-only “ignore skills” list in Atmos prompt injection + +Keep files on disk; strip skill names from Atmos-owned prompts. + +**Pros**: no filesystem mutation. +**Cons**: does not stop terminal Agents that load skills themselves; diverges from Skills page. + +## Decision + +Option A. + +## Open questions (resolved) + +- Disabled storage path → reuse `.atmos/skills/.disabled` under the active root + (project root or workplace root), not a separate `.disable` name. +- Symlink vs copy → same move API after optional ancestor materialization. +- Session tip → always shown in the composer skills popover. diff --git a/specs/APP/APP-040_composer-skills-disable/PRD.md b/specs/APP/APP-040_composer-skills-disable/PRD.md new file mode 100644 index 000000000..5cb08ea78 --- /dev/null +++ b/specs/APP/APP-040_composer-skills-disable/PRD.md @@ -0,0 +1,47 @@ +# PRD · APP-040: Composer Skills Disable + +## Summary + +Users can enable/disable Agent skills from PromptComposer and the terminal AI Input, +using the same filesystem disable mechanism as the Skills management page. Project +toggles stay in sync with that page. Workspace toggles apply only under the current +workplace directory (where sync dirs land), so Agents in that worktree stop discovering +the skill. The UI warns that changes apply to **new** Agent sessions only. + +## Users & jobs + +- **Project operator** on Welcome / PromptComposer: quickly mute noisy project skills + before launching a workspace Agent, without opening Skills. +- **Workspace operator** in a terminal AI Input: mute skills that were synced into the + worktree (symlink or copy) for this workplace only. + +## Must Have + +| ID | Requirement | +|----|-------------| +| M1 | PromptComposer exposes a skills enable/disable control for manageable skills visible in the current project context. | +| M2 | Terminal AI Input exposes the same control. In a project terminal it uses project disable; in a workspace terminal it uses workplace-rooted disable. | +| M3 | Project disable/enable uses the existing Skills Manager move into `project/.atmos/skills/.disabled/...` and stays consistent with the Skills management page. | +| M4 | Workspace disable/enable reuses the same move logic with storage under `workplace/.atmos/skills/.disabled/...`. | +| M5 | Sync-dir symlink and copy placements can both be disabled by moving the workplace-visible skill entry into `.disabled` so Agents no longer discover it. Parent compensated symlinks are materialized as needed so the move does not mutate the project tree. | +| M6 | UI copy warns that disable takes effect on a **new** Agent session; already-initialized sessions keep skills already loaded into the system prompt. | +| M7 | Global skills remain toggleable where `can_toggle` is true (same as Skills page). System / InsideTheProject skills stay non-toggleable. | + +## Nice to Have + +| ID | Requirement | +|----|-------------| +| N1 | Show disabled skills in the composer popover so they can be re-enabled without visiting Skills. | +| N2 | After a toggle, refresh slash-command skill lists that are already open. | + +## Success metrics + +- Project toggle from composer updates Skills page status after refresh without a second API. +- Workspace toggle leaves project skill files untouched when the workplace entry was a compensated symlink/copy. +- Users see the new-session tip before or when toggling. + +## Out of scope + +- Retroactively rewriting an already-running Agent session’s system prompt. +- Cloud-synced disable state across machines. +- Disabling skills inside the read-only `skills/` (InsideTheProject) tree. diff --git a/specs/APP/APP-040_composer-skills-disable/TECH.md b/specs/APP/APP-040_composer-skills-disable/TECH.md new file mode 100644 index 000000000..6195f76b2 --- /dev/null +++ b/specs/APP/APP-040_composer-skills-disable/TECH.md @@ -0,0 +1,71 @@ +# TECH · APP-040: Composer Skills Disable + +## Architecture + +```text +Composer / Terminal AI Input + └─ ComposerSkillsControl (shared UI) + ├─ Project context → skills_list (+ filter) → skills_set_enabled (existing) + └─ Workspace context → skills_scan_root(workplace) → skills_set_enabled(+ scope_root) + └─ core-service SkillManager + ├─ disabled_path = /.atmos/skills/.disabled/ + ├─ ensure_local_movable (workspace only; materialize compensated ancestors) + └─ move_entry_without_following_symlink +``` + +## Data / filesystem + +- Disabled storage relative path stays `DISABLED_STORAGE_REL_PATH = ".atmos/skills/.disabled"`. +- Project root = project `main_file_path`. +- Workspace root = workspace `local_path` (git worktree). +- New manageable scope string: `"workspace"`. +- Skill ids: `workspace::{workspace_id}::{name}` (same builder as project/global). + +## Backend (`crates/core-service`) + +1. `is_manageable_scope` includes `"workspace"`. +2. `SkillScanner::scan_roots(roots, mode)` where each root is `(scope, id, name, path)`. + Existing `scan_all(project_paths)` remains a thin wrapper (global + system + projects). +3. `SkillManager::set_enabled` accepts optional extra roots (workspace) merged into path records. +4. `disabled_path_for` handles `scope == "workspace"` like `project` (lookup root by id). +5. Before a **workspace** disable/enable move, call `ensure_entry_local_to_root(root, path)`: + - If `path` (or the path being restored) is a symlink → no-op (existing move is safe). + - Else walk ancestors under `root`; for each symlink ancestor `S → T`, replace `S` with a + real directory and recreate each child of `T` as a symlink `S/child → T/child` + (absolute target). Repeat until the skill entry itself is a symlink or a real + local directory under `root`. + - Then `move_entry_without_following_symlink` as today. + +## API (`apps/api` WS) + +| Action | Change | +|--------|--------| +| `skills_set_enabled` | Optional `scope_root: { id, name, path, scope }` so workspace roots participate. | +| `skills_scan_root` (new) | Scan a single absolute root with given scope/id/name; no disk-cache (composer freshness). | +| `skills_list` | Unchanged for Skills page / project composer (project disable stays shared). | + +## Frontend (`apps/web`) + +- Shared `ComposerSkillsControl` under `features/skills/components/`. +- Mount in Welcome PromptComposer chrome (project id + project path). +- Mount in `TerminalAgentInputShell` footer: + - If `localPath` equals a workspace worktree → workspace mode. + - Else → project mode (use `activeProjectId` / project root). +- Popover: list toggleable skills, Switch per skill, muted tip about new sessions. +- On success: `forceRefreshSkillsList` / invalidate; toast only on error (inline state for success). +- i18n keys in `Welcome` / `terminal.agentInput` / shared `skills.composerDisable` namespaces (`en` + `zh`). + +## Risks + +| Risk | Mitigation | +|------|------------| +| `rename` through a parent symlink mutates the project | Materialize ancestors first in workspace mode | +| Materialization changes workplace tree shape | Only on toggle; children remain symlinks to project | +| Stale Agent session still sees skill | Explicit UI tip (M6) | + +## Rollout + +1. core-service scan/manage + materialize + tests +2. API WS actions +3. Shared UI control + wire PromptComposer + terminal AI Input +4. i18n + TEST coverage status diff --git a/specs/APP/APP-040_composer-skills-disable/TEST.md b/specs/APP/APP-040_composer-skills-disable/TEST.md new file mode 100644 index 000000000..8bdba086c --- /dev/null +++ b/specs/APP/APP-040_composer-skills-disable/TEST.md @@ -0,0 +1,80 @@ +# TEST · APP-040: Composer Skills Disable + +## Test strategy + +- **Rust unit tests** for workspace disable with copy and parent-symlink compensation, + and for project disable regression. +- **Bun / component tests** optional for popover tip visibility; not required for M1–M5. +- **Manual / agent-browser**: composer + terminal AI Input toggle smoke. + +## Coverage map + +| PRD | Scenarios | +|-----|-----------| +| M3 | S1 | +| M4 / M5 copy | S2 | +| M5 symlink | S3 | +| M6 | S4 | +| M1 / M2 | S5 | + +## Execution map + +| ID | Level | Tool | Target | Status | +|----|-------|------|--------|--------| +| S1 | unit | cargo | `core-service` skill tests | pending | +| S2 | unit | cargo | `core-service` skill tests | pending | +| S3 | unit | cargo | `core-service` skill tests | pending | +| S4 | manual | UI | composer popover tip | pending | +| S5 | manual | UI | Welcome + terminal AI Input | pending | + +## Scenarios + +### S1 — Project disable matches Skills page storage + +**Given** a project skill at `/.claude/skills/demo` +**When** `SkillManager::set_enabled(..., false)` +**Then** the entry lives under `/.atmos/skills/.disabled/.claude/skills/demo` and scan status is `disabled`. + +### S2 — Workspace copy disable is workplace-local + +**Given** a copied skill under `/.claude/skills/demo` +**When** workspace `set_enabled(false)` with workplace scope root +**Then** skill moves to `/.atmos/skills/.disabled/...` and project tree is unchanged. + +### S3 — Workspace parent-symlink disable does not mutate project + +**Given** `/.claude` → `/.claude` and skill `demo` under project +**When** workspace disable for that skill +**Then** project `demo` still exists; workplace no longer exposes an enabled skill at the original path (entry under workplace `.disabled` as a moved symlink after materialization). + +### S4 — New-session tip + +**Given** the composer skills popover is open +**When** the user views it +**Then** copy states disable applies to new Agent sessions only. + +### S5 — Surfaces wired + +**Given** Welcome PromptComposer and a workspace terminal AI Input +**When** the skills control is opened +**Then** toggleable skills for that context are listed and toggling succeeds. + +## Acceptance criteria + +- [ ] S1–S3 automated and green +- [ ] M6 tip present in both surfaces +- [ ] Project toggle remains visible/consistent on Skills page after refresh + +## Coverage Status + +_Updated 2026-07-22_ + +| ID | Status | Evidence | +|----|--------|----------| +| S1 | pass | `cargo +stable test -p core-service --lib service::skill::tests::project_disable_moves_skill_into_disabled_storage` | +| S2 | pass | `…workspace_copy_disable_is_local_to_workplace` | +| S3 | pass | `…workspace_parent_symlink_disable_does_not_mutate_project` | +| S4 | pending | Manual — composer popover shows `skills.composerDisable.sessionTip` | +| S5 | pending | Manual — Welcome PromptComposer + terminal AI Input skills control | + +Regression: `cargo +stable check -p api` green after WS `skills_scan_root` / `skills_set_enabled.scope_root` additions. diff --git a/specs/README.md b/specs/README.md index cee45f0ff..938bde7e4 100644 --- a/specs/README.md +++ b/specs/README.md @@ -117,6 +117,7 @@ These files are not requirements sources. Requirements live in `PRD.md`, archite | **APP-037** | Canvas Local Documents | `specs/APP/APP-037_canvas-local-documents/` (`BRAINSTORM.md`, `PRD.md`, `TECH.md`, `TEST.md`) | | **APP-038** | Onboarding Page | `specs/APP/APP-038_onboarding-page/` (`BRAINSTORM.md`, `PRD.md`, `TECH.md`, `TEST.md`) | | **APP-039** | Terminal `/spawn` Command | `specs/APP/APP-039_terminal-spawn-command/` (`BRAINSTORM.md`, `PRD.md`, `TECH.md`, `TEST.md`) | +| **APP-040** | Composer Skills Disable | `specs/APP/APP-040_composer-skills-disable/` (`BRAINSTORM.md`, `PRD.md`, `TECH.md`, `TEST.md`) | | **QUALITY-001** | Large File Code Debt Cleanup | `specs/APP/QUALITY-001_large-file-code-debt-cleanup/` (`TECH.md`, `TEST.md`) | | **QUALITY-002** | Spec Test Execution Loop | `specs/APP/QUALITY-002_spec-test-execution-loop/` (`TECH.md`, `TEST.md`) | | **QUALITY-003** | Playwright E2E Harness | `specs/APP/QUALITY-003_playwright-e2e-harness/` (`TECH.md`, `TEST.md`) | From 5c5319756437fb40a617decc28c4e2163c908741 Mon Sep 17 00:00:00 2001 From: AarynLu Date: Wed, 22 Jul 2026 10:22:46 +0800 Subject: [PATCH 2/5] fix: Pass the real project GUID as projectId through the side-chat chain, and use that for project-mode skills context instead of workspaceId. --- .../features/canvas/components/CanvasTerminalCard.tsx | 1 + .../terminal/components/TerminalSideChatLayer.tsx | 3 +++ .../terminal/components/TerminalSideChatModal.tsx | 9 ++++++--- .../components/terminal-mosaic-scoped-pane-window.tsx | 1 + .../components/terminal-mosaic-workspace-pane-window.tsx | 1 + .../features/terminal/hooks/use-terminal-side-chats.tsx | 3 +++ 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx b/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx index 4be3f7a38..3fbbeb46d 100644 --- a/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx +++ b/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx @@ -161,6 +161,7 @@ function CanvasTerminalCardInner({ shape }: { shape: CanvasTerminalShape }) { startSideChat, } = useTerminalSideChats({ workspaceId: shape.props.workspaceId, + projectId: shape.props.contextScope === "project" ? shape.props.workspaceId : null, projectName: shape.props.projectName, workspaceName: shape.props.workspaceName, localPath: shape.props.localPath || null, diff --git a/apps/web/src/features/terminal/components/TerminalSideChatLayer.tsx b/apps/web/src/features/terminal/components/TerminalSideChatLayer.tsx index bead6c143..159e670b0 100644 --- a/apps/web/src/features/terminal/components/TerminalSideChatLayer.tsx +++ b/apps/web/src/features/terminal/components/TerminalSideChatLayer.tsx @@ -20,6 +20,7 @@ interface TerminalSideChatLayerProps { onInteraction?: (event: Event | React.SyntheticEvent) => void; onReady: (record: LocalSideChatRecord) => void; onSelectSideChat: (sideChatId: string) => void; + projectId?: string | null; projectName?: string | null; projectRootPath?: string | null; records: LocalSideChatRecord[]; @@ -34,6 +35,7 @@ interface TerminalSideChatLayerProps { export function TerminalSideChatLayer({ localPath, + projectId, projectName, projectRootPath, records, @@ -66,6 +68,7 @@ export function TerminalSideChatLayer({ void; onReady: (record: LocalSideChatRecord) => void; onSelectSideChat: (sideChatId: string) => void; + projectId?: string | null; projectName?: string | null; projectRootPath?: string | null; records: LocalSideChatRecord[]; @@ -57,6 +58,7 @@ export interface TerminalSideChatModalProps { export function TerminalSideChatModal({ activeSideChatId, localPath, + projectId, projectName, projectRootPath, records, @@ -84,10 +86,11 @@ export function TerminalSideChatModal({ !!projectRootPath && (localPath === projectRootPath || localPath.replace(/\/$/, "") === projectRootPath.replace(/\/$/, "")); if (isProjectRoot) { + if (!projectId) return null; return { mode: "project" as const, - id: workspaceId, - name: projectName || workspaceId, + id: projectId, + name: projectName || projectId, path: projectRootPath || localPath, }; } @@ -97,7 +100,7 @@ export function TerminalSideChatModal({ name: workspaceName || projectName || workspaceId, path: localPath, }; - }, [localPath, projectName, projectRootPath, workspaceId, workspaceName]); + }, [localPath, projectId, projectName, projectRootPath, workspaceId, workspaceName]); const [readySideChatIds, setReadySideChatIds] = React.useState>(() => new Set()); const [isFocusedWithin, setIsFocusedWithin] = React.useState(true); const modalRef = React.useRef(null); diff --git a/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx b/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx index c107950a7..46089b963 100644 --- a/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx +++ b/apps/web/src/features/terminal/components/terminal-mosaic-scoped-pane-window.tsx @@ -185,6 +185,7 @@ export function TerminalMosaicScopedPaneWindow({ startSideChat, } = useTerminalSideChats({ workspaceId, + projectId: activeProject?.id ?? null, projectName: workspaceInfo?.projectName ?? null, workspaceName: workspaceInfo?.workspaceName ?? null, localPath: workspaceInfo?.localPath ?? null, diff --git a/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx b/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx index fc21e9776..198854979 100644 --- a/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx +++ b/apps/web/src/features/terminal/components/terminal-mosaic-workspace-pane-window.tsx @@ -215,6 +215,7 @@ export function TerminalMosaicWorkspacePaneWindow(props: TerminalMosaicWorkspace startSpawn, } = useTerminalSideChats({ workspaceId, + projectId: activeProject?.id ?? null, projectName: workspaceInfo?.projectName ?? null, workspaceName: workspaceInfo?.workspaceName ?? null, localPath: workspaceInfo?.localPath ?? null, diff --git a/apps/web/src/features/terminal/hooks/use-terminal-side-chats.tsx b/apps/web/src/features/terminal/hooks/use-terminal-side-chats.tsx index f805413af..75e4508ae 100644 --- a/apps/web/src/features/terminal/hooks/use-terminal-side-chats.tsx +++ b/apps/web/src/features/terminal/hooks/use-terminal-side-chats.tsx @@ -32,6 +32,7 @@ import { useTerminalSideChatRecords } from "./use-terminal-side-chat-records"; export interface UseTerminalSideChatsOptions { workspaceId: string; + projectId?: string | null; projectName?: string | null; workspaceName?: string | null; localPath?: string | null; @@ -72,6 +73,7 @@ function buildSpawnTerminalTitle(userPrompt: string): string { export function useTerminalSideChats({ workspaceId, + projectId, projectName, workspaceName, localPath, @@ -327,6 +329,7 @@ export function useTerminalSideChats({ const sideChatLayer = ( Date: Wed, 22 Jul 2026 17:50:46 +0800 Subject: [PATCH 3/5] feat(skills): add Dynamic Skills disable from composer and document it Let users toggle skills from slash commands with workplace-local disable for sync dirs, skip SKILL_DISABLED markers in scans, and add How It Work docs. --- .../docs/(app)/features/skill-management.mdx | 8 + .../(app)/features/skill-management.zh.mdx | 8 + .../(app)/how-it-works/dynamic-skills.mdx | 150 ++++ .../(app)/how-it-works/dynamic-skills.zh.mdx | 150 ++++ .../content/docs/(app)/how-it-works/meta.json | 4 + .../docs/(app)/how-it-works/meta.zh.json | 4 + apps/docs/content/docs/(app)/meta.json | 2 + apps/docs/content/docs/(app)/meta.zh.json | 2 + apps/web/messages/en.json | 28 +- apps/web/messages/zh.json | 28 +- .../hooks/use-composer-disable-skills.ts | 148 ++++ .../skills/lib/skill-disable-protocol.ts | 94 +++ .../components/TerminalAgentInputOverlay.tsx | 281 +++++-- .../components/TerminalAgentInputPopovers.tsx | 19 +- .../components/TerminalAgentInputShell.tsx | 6 + .../welcome/components/PromptComposer.tsx | 390 +++++++++- .../components/SlashCommandPopover.tsx | 688 ++++++++++++------ .../components/WelcomeComposerCard.tsx | 6 + .../welcome/components/WelcomePage.tsx | 212 +++++- .../hooks/use-welcome-slash-navigation.ts | 17 +- .../welcome/hooks/use-welcome-slash-search.ts | 46 +- .../welcome/lib/slash-skill-context.ts | 4 +- crates/core-service/src/service/skill.rs | 85 ++- .../core-service/src/service/skill/scanner.rs | 5 +- .../core-service/src/service/skill/support.rs | 24 +- .../core-service/src/service/skill/tests.rs | 57 +- .../BRAINSTORM.md | 33 +- .../APP-040_composer-skills-disable/PRD.md | 44 +- .../APP-040_composer-skills-disable/TECH.md | 82 ++- .../APP-040_composer-skills-disable/TEST.md | 76 +- 30 files changed, 2277 insertions(+), 424 deletions(-) create mode 100644 apps/docs/content/docs/(app)/how-it-works/dynamic-skills.mdx create mode 100644 apps/docs/content/docs/(app)/how-it-works/dynamic-skills.zh.mdx create mode 100644 apps/docs/content/docs/(app)/how-it-works/meta.json create mode 100644 apps/docs/content/docs/(app)/how-it-works/meta.zh.json create mode 100644 apps/web/src/features/skills/hooks/use-composer-disable-skills.ts create mode 100644 apps/web/src/features/skills/lib/skill-disable-protocol.ts diff --git a/apps/docs/content/docs/(app)/features/skill-management.mdx b/apps/docs/content/docs/(app)/features/skill-management.mdx index b763a1b9f..815af1923 100644 --- a/apps/docs/content/docs/(app)/features/skill-management.mdx +++ b/apps/docs/content/docs/(app)/features/skill-management.mdx @@ -29,6 +29,14 @@ Skills are different from agent definitions. Use **Settings** -> **Code Agent** 3. Enable skills that match your current project. 4. Disable or remove skills that add noise. +## Dynamic Skills (composer & terminal) + +Need to mute a skill without leaving Welcome or the terminal AI Input? Type `/` and open **Dynamic Skills**. + +It uses the same enable/disable files as this page. Project toggles stay in sync here; workspace toggles apply only under the current workplace (important when sync dirs use symlink or copy). + +See [Dynamic Skills](/how-it-works/dynamic-skills) for the full walkthrough—scope, on-disk layout, and how workspace symlink sync is kept workplace-local. + ## Tips - One skill, one job—split oversized skills into smaller ones. diff --git a/apps/docs/content/docs/(app)/features/skill-management.zh.mdx b/apps/docs/content/docs/(app)/features/skill-management.zh.mdx index 37915419a..f1081e9f7 100644 --- a/apps/docs/content/docs/(app)/features/skill-management.zh.mdx +++ b/apps/docs/content/docs/(app)/features/skill-management.zh.mdx @@ -29,6 +29,14 @@ Skills 是给 Agent 的可复用指令。它们可以记录 Review 清单、写 3. 启用符合当前项目的技能。 4. 禁用或移除会增加噪音的技能。 +## Dynamic Skills(Composer 与终端) + +不想离开 Welcome 或终端 AI Input,又要临时关掉某个技能?输入 `/`,打开 **Dynamic Skills**。 + +它和本页使用同一套启用/禁用文件。Project 开关会与这里同步;Workspace 开关只作用于当前 workplace(在 sync dirs 使用 symlink 或 copy 时尤其重要)。 + +完整说明见 [Dynamic Skills](/zh/how-it-works/dynamic-skills):作用域、磁盘布局,以及如何让 Workspace symlink 同步保持 workplace 本地。 + ## 使用建议 - 一个技能只做一件事;把过大的技能拆小。 diff --git a/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.mdx b/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.mdx new file mode 100644 index 000000000..22f86d92c --- /dev/null +++ b/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.mdx @@ -0,0 +1,150 @@ +--- +title: Dynamic Skills +description: Temporarily hide skills from Agents without leaving the composer or terminal. +--- + +Sometimes a skill is useful tomorrow and noisy today. **Dynamic Skills** lets you turn skills off (and back on) right where you prompt—Welcome / PromptComposer or the terminal AI Input—without opening the Skills page. + +It uses the same on-disk enable/disable mechanism as [Skill Management](/features/skill-management). This page explains what that means for Agents, projects, and workspaces. + +## When to use it + +| Situation | What Dynamic Skills does | +|-----------|--------------------------| +| A project skill keeps steering the Agent the wrong way | Disable it for this project; Skills page stays in sync | +| A workspace synced skills from the project, but this task does not need one | Disable under the **workplace** only; the project copy stays untouched | +| You already started an Agent session | Disable removes the live `SKILL.md` entrypoint so the current session cannot reload that path | + +Global skills remain toggleable when Atmos marks them manageable. System and Inside Project skills stay read-only. + +## Open Dynamic Skills + +1. Focus the Welcome composer or the terminal AI Input. +2. Type `/` to open the slash command menu. +3. Choose **Dynamic Skills**. +4. Toggle skills in the list. Use **Back** or `Esc` to return to the slash menu. + +While the session is open, a red **Dynamic Skills** chip appears in the composer. Filter inside the chip, hover for a short summary of what you changed, and close the session when you are done. + +Disabled skills may still show in the `/` skill-insert list with a **Disabled** badge—they are not selectable until you turn them back on. + +## Project vs workspace scope + +```text + ┌─────────────────────┐ + Welcome / │ Project context │ → disable under the project root + PromptComposer└─────────────────────┘ (same as Skills page) + + ┌─────────────────────┐ + Terminal AI │ Project terminal │ → project root + Input ├─────────────────────┤ + │ Workspace terminal │ → this workplace folder only + └─────────────────────┘ +``` + +- **Project toggle** — moves the skill into `project/.atmos/skills/.disabled/...`. Opening Skills later shows the same disabled state. +- **Workspace toggle** — moves the workplace-visible entry into `workplace/.atmos/skills/.disabled/...`. Other workspaces and the project tree are not the target. + +That split matters because workspaces often receive agent folders through **Ignored Folder Sync** (symlink or copy). Disabling “for this worktree” should not silently rewrite the project’s skills. + +## What disable looks like on disk + +Atmos does not flip a cloud flag. It changes files Agents discover. + +```text +Before (enabled) After (disabled) + +.claude/skills/demo/ .claude/skills/demo/ + SKILL.md SKILL_DISABLED.md ← marker only + references/... (no SKILL.md) + ... + + .atmos/skills/.disabled/ + .claude/skills/demo/ + SKILL.md ← real skill tree + references/... +``` + +Two ideas to keep straight: + +1. **Storage of truth** — the real skill files live under `.atmos/skills/.disabled/...` while disabled. +2. **Live marker** — the original folder keeps a `SKILL_DISABLED.md` file (never a fake `SKILL.md`). New discovery skips it; a session that still tries the old path no longer finds `SKILL.md`. + +Re-enable removes the marker, then moves the skill tree back to the live path. + +## Workspace sync: copy vs symlink + +### Copy sync + +The workplace already has its own files. Disable is a normal local move under the workplace root. The project’s skill directory is unchanged. + +```text +project/.claude/skills/demo/SKILL.md ← stays +workplace/.claude/skills/demo/ ← moved to workplace .disabled + + SKILL_DISABLED.md at live path +``` + +### Symlink sync (parent link) + +Sync often looks like this at first: + +```text +workplace/ + .claude ──────────symlink──────────► project/.claude + skills/demo/SKILL.md + skills/keep/SKILL.md +``` + +If Atmos renamed `workplace/.claude/skills/demo` while that path still walked through the parent symlink, the rename would move the **project** folder. To keep the disable workplace-local, Atmos first **materializes** ancestor symlinks: + +```text +Step 1 — replace the big parent link with a real folder + child links + +workplace/ + .claude/ ← real directory now + skills ──symlink──► project/.claude/skills + +Step 2 — keep splitting until the skill entry itself is a workplace symlink + +workplace/ + .claude/ + skills/ ← real directory + demo ──symlink──► project/.claude/skills/demo + keep ──symlink──► project/.claude/skills/keep + +Step 3 — move only that workplace symlink into .disabled, then write the marker + +workplace/.atmos/skills/.disabled/.../demo ← often still a symlink to project +workplace/.claude/skills/demo/SKILL_DISABLED.md +project/.claude/skills/demo/SKILL.md ← untouched +``` + +So “materialize” means: **turn one thick symlink into a local folder skeleton whose leaves still point at the project**, then disable by moving the leaf link—not by editing project files. + +### What re-enable restores + +Enable moves back whatever was stored under `.disabled`: + +| How the skill arrived | What `.disabled` usually holds | After enable | +|-----------------------|--------------------------------|--------------| +| Copy | Real skill files | Real files at the live path | +| Symlink (after materialize) | The symlink node pointing at the project | That symlink again | + +Materialized parent folders (for example `workplace/.claude` becoming a real directory) are **not** rebuilt into the original single parent symlink. Only the skill you toggled is restored. + +## Project disable and existing workspaces + +If a workplace still shares the project tree through a symlink, a **project-level** disable changes the shared live path. Agents in that workplace also stop seeing `SKILL.md` through the link. + +Copy-synced workspaces keep their own files until you disable inside that workplace. + +## Limits (honest expectations) + +- Atmos does not own Agent Runtime memory. Removing the filesystem entrypoint is best-effort for the current session; a model that already baked skill metadata into an earlier turn may still mention it. +- There is no cloud-synced disable state across machines—the filesystem under each root is the contract. +- Inside Project (`skills/` read-only tree) skills cannot be toggled here. + +## Related + +- [Skill Management](/features/skill-management) — browse, enable, disable, and delete from the Skills page +- [Project & Workspace Manager](/workflows/project-and-workspace-manager) — projects, workspaces, and ignored-folder sync diff --git a/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.zh.mdx b/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.zh.mdx new file mode 100644 index 000000000..32e518b9a --- /dev/null +++ b/apps/docs/content/docs/(app)/how-it-works/dynamic-skills.zh.mdx @@ -0,0 +1,150 @@ +--- +title: Dynamic Skills +description: 不用离开 Composer 或终端,就能临时对 Agent 隐藏技能。 +--- + +有时某个技能明天有用、今天却很吵。**Dynamic Skills** 让你在提问的地方——Welcome / PromptComposer 或终端 AI Input——直接开关技能,而不必打开 Skills 页面。 + +它和 [技能管理](/zh/features/skill-management) 用的是同一套磁盘启用/禁用机制。这篇说明这对 Agent、Project 和 Workspace 分别意味着什么。 + +## 什么时候用 + +| 场景 | Dynamic Skills 会做什么 | +|------|-------------------------| +| 项目技能总把 Agent 带偏 | 在项目里禁用;Skills 页面保持同步 | +| Workspace 从项目同步了技能,但这次任务用不到 | 只在当前 **workplace** 禁用;项目里的副本不动 | +| Agent session 已经在跑 | 禁用会去掉 live 路径上的 `SKILL.md`,当前 session 无法再加载该入口 | + +Global 技能在 Atmos 标记为可管理时仍可开关。System 与 Inside Project 技能保持只读。 + +## 打开 Dynamic Skills + +1. 聚焦 Welcome Composer 或终端 AI Input。 +2. 输入 `/` 打开 slash 命令菜单。 +3. 选择 **Dynamic Skills**。 +4. 在列表里开关技能。用 **Back** 或 `Esc` 回到 slash 菜单。 + +会话开启时,Composer 里会出现红色 **Dynamic Skills** chip。可在 chip 内筛选,悬停查看本次变更摘要,结束后关闭会话即可。 + +在 `/` 插入技能列表里,已禁用的技能仍可能出现,并带 **Disabled** 徽章——在重新启用前不可选。 + +## Project 与 Workspace 作用域 + +```text + ┌─────────────────────┐ + Welcome / │ Project 上下文 │ → 禁用落在项目根 + PromptComposer└─────────────────────┘ (与 Skills 页面一致) + + ┌─────────────────────┐ + 终端 AI │ Project 终端 │ → 项目根 + Input ├─────────────────────┤ + │ Workspace 终端 │ → 仅当前 workplace 目录 + └─────────────────────┘ +``` + +- **Project 开关** — 把技能移到 `project/.atmos/skills/.disabled/...`。之后打开 Skills 会看到相同的禁用状态。 +- **Workspace 开关** — 把 workplace 可见的入口移到 `workplace/.atmos/skills/.disabled/...`。目标不是其他 workspace,也不是项目树本身。 + +这个区分很重要:Workspace 常通过 **Ignored Folder Sync**(symlink 或 copy)拿到 Agent 目录。「只对这个 worktree 禁用」不应悄悄改写项目技能。 + +## 禁用后磁盘上长什么样 + +Atmos 不会拨一个云端开关,而是改 Agent 会发现的文件。 + +```text +禁用前 禁用后 + +.claude/skills/demo/ .claude/skills/demo/ + SKILL.md SKILL_DISABLED.md ← 只有标记 + references/... (没有 SKILL.md) + ... + + .atmos/skills/.disabled/ + .claude/skills/demo/ + SKILL.md ← 真实技能树 + references/... +``` + +记住两件事: + +1. **真相来源** — 禁用期间,真实技能文件在 `.atmos/skills/.disabled/...`。 +2. **Live 标记** — 原目录只保留 `SKILL_DISABLED.md`(绝不会留一个假的 `SKILL.md`)。新的发现会跳过它;仍去读旧路径的 session 也找不到 `SKILL.md`。 + +重新启用会先去掉标记,再把技能树移回 live 路径。 + +## Workspace 同步:Copy 与 Symlink + +### Copy 同步 + +Workplace 已有自己的文件。禁用就是在 workplace 根下做一次本地移动。项目里的技能目录不变。 + +```text +project/.claude/skills/demo/SKILL.md ← 保留 +workplace/.claude/skills/demo/ ← 移到 workplace 的 .disabled + + live 路径留下 SKILL_DISABLED.md +``` + +### Symlink 同步(父目录链接) + +刚同步时经常是这样: + +```text +workplace/ + .claude ──────────symlink──────────► project/.claude + skills/demo/SKILL.md + skills/keep/SKILL.md +``` + +如果仍穿过父 symlink 去 rename `workplace/.claude/skills/demo`,操作系统会动到 **项目** 里的目录。为了让禁用只作用于 workplace,Atmos 会先 **materialize(物化)** 祖先 symlink: + +```text +步骤 1 — 把「一整棵大 symlink」换成真实目录 + 子项链接 + +workplace/ + .claude/ ← 现在是真实目录 + skills ──symlink──► project/.claude/skills + +步骤 2 — 继续拆,直到 skill 入口本身是 workplace 上的 symlink + +workplace/ + .claude/ + skills/ ← 真实目录 + demo ──symlink──► project/.claude/skills/demo + keep ──symlink──► project/.claude/skills/keep + +步骤 3 — 只搬走这个 workplace symlink,再写标记 + +workplace/.atmos/skills/.disabled/.../demo ← 常常仍是指向 project 的 symlink +workplace/.claude/skills/demo/SKILL_DISABLED.md +project/.claude/skills/demo/SKILL.md ← 不动 +``` + +所以 materialize 的意思是:**把一层厚 symlink 落成「本地目录骨架,叶子仍指向项目」**,再通过移动叶子链接来禁用——而不是改项目文件。 + +### 重新启用恢复什么 + +启用会把 `.disabled` 里存的那一份原样搬回: + +| 技能如何进来 | `.disabled` 里通常是什么 | 启用后 | +|--------------|--------------------------|--------| +| Copy | 真实技能文件 | Live 路径上的真实文件 | +| Symlink(物化后) | 指向项目的 symlink 节点 | 还是那个 symlink | + +被物化过的父目录(例如 `workplace/.claude` 变成真实目录)**不会**在启用时拼回最初那一个父 symlink。只有你开关过的那个技能会恢复。 + +## 在项目里禁用,对已有 Workspace 的影响 + +若某个 workplace 仍通过 symlink 共享项目树,**项目级**禁用会改共享 live 路径。该 workplace 里的 Agent 也会因此看不到 `SKILL.md`。 + +Copy 同步的 workspace 保留自己的文件,直到你在那个 workplace 里禁用。 + +## 边界(如实说明) + +- Atmos 不拥有 Agent Runtime 内存。去掉文件系统入口对当前 session 是尽力而为;模型若已在更早轮次写入技能元数据,仍可能提到它。 +- 没有跨机器的云端禁用状态——各根目录下的文件系统就是契约。 +- Inside Project(只读 `skills/` 树)不能在这里开关。 + +## 相关 + +- [技能管理](/zh/features/skill-management) — 在 Skills 页面浏览、启用、禁用与删除 +- [项目与工作区管理](/zh/workflows/project-and-workspace-manager) — 项目、Workspace 与忽略目录同步 diff --git a/apps/docs/content/docs/(app)/how-it-works/meta.json b/apps/docs/content/docs/(app)/how-it-works/meta.json new file mode 100644 index 000000000..5fc61dc87 --- /dev/null +++ b/apps/docs/content/docs/(app)/how-it-works/meta.json @@ -0,0 +1,4 @@ +{ + "title": "How It Work", + "pages": ["dynamic-skills"] +} diff --git a/apps/docs/content/docs/(app)/how-it-works/meta.zh.json b/apps/docs/content/docs/(app)/how-it-works/meta.zh.json new file mode 100644 index 000000000..af2f1baf9 --- /dev/null +++ b/apps/docs/content/docs/(app)/how-it-works/meta.zh.json @@ -0,0 +1,4 @@ +{ + "title": "工作原理", + "pages": ["dynamic-skills"] +} diff --git a/apps/docs/content/docs/(app)/meta.json b/apps/docs/content/docs/(app)/meta.json index 9750f7e29..a54bf8872 100644 --- a/apps/docs/content/docs/(app)/meta.json +++ b/apps/docs/content/docs/(app)/meta.json @@ -10,6 +10,8 @@ "...features", "---Workflows---", "...workflows", + "---How It Work---", + "...how-it-works", "---Help & reference---", "...reference" ] diff --git a/apps/docs/content/docs/(app)/meta.zh.json b/apps/docs/content/docs/(app)/meta.zh.json index 9c31443e1..f62afc168 100644 --- a/apps/docs/content/docs/(app)/meta.zh.json +++ b/apps/docs/content/docs/(app)/meta.zh.json @@ -10,6 +10,8 @@ "...features", "---工作流---", "...workflows", + "---How It Work---", + "...how-it-works", "---帮助与参考---", "...reference" ] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a11eaff40..c52e1a4be 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3108,7 +3108,12 @@ "codeAgents": "Code agents", "noAgentsAvailable": "No agents available", "hidden": "Hidden", - "hiddenSummary": "{skills} skills · {projects} projects · {agents} agents hidden" + "hiddenSummary": "{skills} skills · {projects} projects · {agents} agents hidden", + "disabled": "Disabled", + "disableSkill": { + "label": "Dynamic Skills", + "description": "Dynamically adjust which skills Agents can see — takes effect in the current session" + } } }, "projectContext": { @@ -6712,8 +6717,9 @@ "composerDisable": { "triggerLabel": "Manage skills", "triggerShort": "Skills", - "title": "Skills", - "sessionTip": "Disabling a skill takes effect in a new Agent session. Skills already loaded into the current session’s system prompt stay available until you start a new session.", + "title": "Dynamic Skills", + "back": "Back", + "sessionTip": "Toggles change skill visibility for Agents right away: disabled skills are moved out of discovery paths and leave a SKILL_DISABLED.md marker so the current session cannot load the original SKILL.md entrypoint.", "loading": "Loading skills...", "empty": "No manageable skills in this context.", "scope": { @@ -7856,6 +7862,22 @@ "spawnCommand": { "description": "Spawn a new terminal panel with this terminal context" }, + "disableSkillCommand": { + "label": "Dynamic Skills", + "description": "Dynamically adjust which skills Agents can see — takes effect in the current session" + }, + "skillDisable": { + "chip": "Dynamic Skills", + "chipTooltip": "Filter and toggle skills for Agents in this context", + "filterPlaceholder": "filter…", + "filterAria": "Filter skills", + "deleteAgainHint": "Press Delete again to close Dynamic Skills", + "actionEnabled": "+", + "actionDisabled": "−", + "countdown": "{seconds}s", + "tooltipEnabled": "Added: {names}", + "tooltipDisabled": "Removed: {names}" + }, "selectionContext": { "copy": "Copy", "addAsContext": "Add as context", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 5dcd4e981..6be610250 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -3108,7 +3108,12 @@ "codeAgents": "代码 Agent", "noAgentsAvailable": "没有可用 Agent", "hidden": "隐藏", - "hiddenSummary": "已隐藏 {skills} 个技能 · {projects} 个项目 · {agents} 个 Agent" + "hiddenSummary": "已隐藏 {skills} 个技能 · {projects} 个项目 · {agents} 个 Agent", + "disabled": "已禁用", + "disableSkill": { + "label": "Dynamic Skills", + "description": "动态调整 Agent 可见的技能,当前 session 即可生效" + } } }, "projectContext": { @@ -6712,8 +6717,9 @@ "composerDisable": { "triggerLabel": "管理技能", "triggerShort": "技能", - "title": "技能", - "sessionTip": "禁用技能后,需要新的 Agent session 才会生效。当前已初始化的 session 里,技能已经加载进系统提示词,仍会保持可用,直到你开启新的 session。", + "title": "Dynamic Skills", + "back": "返回", + "sessionTip": "开关会立刻改变 Agent 对技能的可见性:禁用后技能会移出发现路径,并在原位置留下 SKILL_DISABLED.md 标记,当前 session 也无法再加载原来的 SKILL.md 入口。", "loading": "正在加载技能…", "empty": "当前上下文没有可管理的技能。", "scope": { @@ -7856,6 +7862,22 @@ "spawnCommand": { "description": "带上此终端上下文 spawn 出一个新的终端面板" }, + "disableSkillCommand": { + "label": "Dynamic Skills", + "description": "动态调整 Agent 可见的技能,当前 session 即可生效" + }, + "skillDisable": { + "chip": "Dynamic Skills", + "chipTooltip": "筛选并开关当前上下文中的技能", + "filterPlaceholder": "筛选…", + "filterAria": "筛选技能", + "deleteAgainHint": "再按一次删除键关闭 Dynamic Skills", + "actionEnabled": "+", + "actionDisabled": "−", + "countdown": "{seconds}s", + "tooltipEnabled": "添加:{names}", + "tooltipDisabled": "删除:{names}" + }, "selectionContext": { "copy": "复制", "addAsContext": "添加为上下文", diff --git a/apps/web/src/features/skills/hooks/use-composer-disable-skills.ts b/apps/web/src/features/skills/hooks/use-composer-disable-skills.ts new file mode 100644 index 000000000..17a0a4439 --- /dev/null +++ b/apps/web/src/features/skills/hooks/use-composer-disable-skills.ts @@ -0,0 +1,148 @@ +"use client"; + +import React from "react"; +import { + forceRefreshSkillsList, + useInvalidateSkillsList, +} from "@/features/skills/hooks/use-skills-query"; +import { + skillsApi, + type SkillInfo, + type SkillScopeRoot, +} from "@/api/ws/skills-api"; + +export type ComposerSkillsContext = { + mode: "project" | "workspace"; + id: string; + name: string; + path: string; +}; + +function sortSkills(skills: SkillInfo[]) { + return [...skills].sort((a, b) => { + if (a.status !== b.status) { + if (a.status === "enabled") return -1; + if (b.status === "enabled") return 1; + } + return a.name.localeCompare(b.name); + }); +} + +export function useComposerDisableSkills(context: ComposerSkillsContext | null) { + const invalidateSkillsList = useInvalidateSkillsList(); + const [loading, setLoading] = React.useState(false); + const [skills, setSkills] = React.useState([]); + const [pendingId, setPendingId] = React.useState(null); + const [error, setError] = React.useState(null); + + const scopeRoot = React.useMemo(() => { + if (!context) return undefined; + return { + scope: context.mode, + id: context.id, + name: context.name, + path: context.path, + }; + }, [context]); + + const loadSkills = React.useCallback(async () => { + if (!context) { + setSkills([]); + return; + } + setLoading(true); + setError(null); + try { + if (context.mode === "workspace") { + const [rootResult, listResult] = await Promise.all([ + skillsApi.scanRoot({ + scope: "workspace", + id: context.id, + name: context.name, + path: context.path, + }), + skillsApi.list({ forceRefresh: true }), + ]); + const workplace = rootResult.skills.filter((skill) => skill.can_toggle); + const globals = listResult.skills.filter( + (skill) => skill.can_toggle && skill.scope === "global", + ); + const byId = new Map(); + for (const skill of [...workplace, ...globals]) { + byId.set(skill.id, skill); + } + setSkills(sortSkills([...byId.values()])); + } else { + const listResult = await skillsApi.list({ forceRefresh: true }); + setSkills( + sortSkills( + listResult.skills.filter( + (skill) => + skill.can_toggle && + (skill.scope === "global" || + (skill.scope === "project" && skill.project_id === context.id)), + ), + ), + ); + } + } catch (loadError) { + console.error("Failed to load composer skills:", loadError); + setSkills([]); + setError(loadError instanceof Error ? loadError.message : "Failed to load skills"); + } finally { + setLoading(false); + } + }, [context]); + + const setEnabled = React.useCallback( + async (skill: SkillInfo, enabled: boolean) => { + setPendingId(skill.id); + setError(null); + try { + const root = + skill.scope === "workspace" || + (context?.mode === "workspace" && skill.scope !== "global") + ? scopeRoot + : undefined; + await skillsApi.setEnabled(skill.id, enabled, undefined, root); + setSkills((current) => + sortSkills( + current.map((item) => + item.id === skill.id + ? { + ...item, + status: enabled ? "enabled" : "disabled", + placements: item.placements.map((placement) => ({ + ...placement, + status: enabled ? "enabled" : "disabled", + })), + } + : item, + ), + ), + ); + invalidateSkillsList(); + if (skill.scope !== "workspace") { + void forceRefreshSkillsList().catch(() => {}); + } + return true; + } catch (toggleError) { + setError(toggleError instanceof Error ? toggleError.message : "Failed to update skill"); + void loadSkills(); + return false; + } finally { + setPendingId(null); + } + }, + [context?.mode, invalidateSkillsList, loadSkills, scopeRoot], + ); + + return { + error, + loading, + loadSkills, + pendingId, + setEnabled, + skills, + }; +} diff --git a/apps/web/src/features/skills/lib/skill-disable-protocol.ts b/apps/web/src/features/skills/lib/skill-disable-protocol.ts new file mode 100644 index 000000000..cf7c4b49d --- /dev/null +++ b/apps/web/src/features/skills/lib/skill-disable-protocol.ts @@ -0,0 +1,94 @@ +export const SKILL_DISABLE_PROTOCOL = "atmos://skill-disable"; +export const SKILL_DISABLE_DISMISS_SECONDS = 5; + +/** + * One skill touched during a disable-session. Counts only net changes vs the + * status before the first toggle in this session (toggle back → no +/-). + */ +export type SkillDisableSessionAction = { + id: string; + name: string; + /** Enabled state before the first toggle in this session. */ + initialEnabled: boolean; + /** Latest enabled state after toggles in this session. */ + currentEnabled: boolean; +}; + +export type SkillDisableSessionCounts = { + enabled: number; + disabled: number; +}; + +export type SkillDisableSessionNameLists = { + enabled: string[]; + disabled: string[]; +}; + +export function formatSkillDisableProtocol(): string { + return SKILL_DISABLE_PROTOCOL; +} + +export function parseSkillDisableProtocolToken(token: string): boolean { + return token === SKILL_DISABLE_PROTOCOL; +} + +/** + * Remove the skill-disable chip token so it never lands in an Agent / workspace prompt. + * Filter text lives inside the chip DOM and is not part of serialize(), so only the token + * needs stripping. + */ +export function stripSkillDisableSession(text: string): string { + const idx = text.indexOf(SKILL_DISABLE_PROTOCOL); + if (idx < 0) return text; + const before = text.slice(0, idx); + const after = text.slice(idx + SKILL_DISABLE_PROTOCOL.length).replace(/^[\u00A0 ]+/, ""); + return `${before}${after}`.replace(/[ \u00A0]{2,}/g, " ").trimEnd(); +} + +export function upsertSkillDisableSessionAction( + actions: SkillDisableSessionAction[], + skillId: string, + skillName: string, + /** Enabled state before this toggle (from the skill row / prior known state). */ + beforeEnabled: boolean, + /** Enabled state after a successful toggle. */ + afterEnabled: boolean, +): SkillDisableSessionAction[] { + const existing = actions.find((action) => action.id === skillId); + if (existing) { + return actions.map((action) => + action.id === skillId + ? { ...action, name: skillName, currentEnabled: afterEnabled } + : action, + ); + } + return [ + ...actions, + { + id: skillId, + name: skillName, + initialEnabled: beforeEnabled, + currentEnabled: afterEnabled, + }, + ]; +} + +export function skillDisableSessionNameLists( + actions: SkillDisableSessionAction[], +): SkillDisableSessionNameLists { + const enabled: string[] = []; + const disabled: string[] = []; + for (const action of actions) { + if (action.currentEnabled === action.initialEnabled) continue; + if (action.currentEnabled) enabled.push(action.name); + else disabled.push(action.name); + } + return { enabled, disabled }; +} + +export function skillDisableSessionCounts( + actions: SkillDisableSessionAction[], +): SkillDisableSessionCounts { + const lists = skillDisableSessionNameLists(actions); + return { enabled: lists.enabled.length, disabled: lists.disabled.length }; +} diff --git a/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx b/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx index 46a010dd6..29b42e9be 100644 --- a/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx +++ b/apps/web/src/features/terminal/components/TerminalAgentInputOverlay.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { cn } from "@workspace/ui"; import { Bot } from "lucide-react"; +import type { SkillInfo } from "@/api/ws-api"; import { AgentIcon } from "@/features/agent/components/AgentIcon"; import { buildPipedAgentTerminalInput, @@ -18,9 +19,16 @@ import { } from "@/features/welcome/components/PromptComposer"; import { WelcomeAgentSelector } from "@/features/welcome/components/WelcomeComposerControls"; import { - ComposerSkillsControl, + useComposerDisableSkills, type ComposerSkillsContext, -} from "@/features/skills/components/ComposerSkillsControl"; +} from "@/features/skills/hooks/use-composer-disable-skills"; +import { + SKILL_DISABLE_DISMISS_SECONDS, + stripSkillDisableSession, + upsertSkillDisableSessionAction, + type SkillDisableSessionAction, +} from "@/features/skills/lib/skill-disable-protocol"; +import type { SlashPopoverView } from "@/features/welcome/components/SlashCommandPopover"; import type { AgentMenuOption } from "@/features/welcome/lib/welcome-page-helpers"; import { type MentionNavItem, @@ -145,6 +153,14 @@ export const TerminalAgentInputOverlay = React.forwardRef< const [flyingMessage, setFlyingMessage] = React.useState(null); const [mentionPopover, setMentionPopover] = React.useState(null); const [slashPopover, setSlashPopover] = React.useState(null); + const [slashPopoverView, setSlashPopoverView] = React.useState("menu"); + const [skillDisableFilter, setSkillDisableFilter] = React.useState(""); + const [skillDisableSessionActions, setSkillDisableSessionActions] = React.useState< + SkillDisableSessionAction[] + >([]); + const suppressSlashCancelRef = React.useRef(false); + const slashPopoverViewRef = React.useRef("menu"); + slashPopoverViewRef.current = slashPopoverView; const [pendingSidePrompt, setPendingSidePrompt] = React.useState(null); const [pendingSideContexts, setPendingSideContexts] = React.useState([]); const [pendingCommandKind, setPendingCommandKind] = React.useState<"side" | "spawn">("side"); @@ -193,6 +209,15 @@ export const TerminalAgentInputOverlay = React.forwardRef< const shouldShowSideChatAgentSelector = isContextCommandActive && sideChatAgentMenuOptions.length > 0; + const { + error: disableSkillsError, + loading: disableSkillsLoading, + loadSkills: loadDisableSkills, + pendingId: disableSkillsPendingId, + setEnabled: setDisableSkillEnabled, + skills: disableSkillsList, + } = useComposerDisableSkills(skillsContext ?? null); + const slashCommands = React.useMemo(() => { const query = slashPopover?.query.trim().toLowerCase() ?? ""; const commands: SlashCommandOption[] = []; @@ -210,8 +235,24 @@ export const TerminalAgentInputOverlay = React.forwardRef< description: t("spawnCommand.description"), }); } + if ( + skillsContext && + (!query || + "dynamic-skills".includes(query) || + "dynamic skills".includes(query) || + "disable-skill".includes(query) || + "disable skill".includes(query) || + "disable".includes(query) || + "skill".includes(query)) + ) { + commands.push({ + id: "dynamic-skills", + label: t("disableSkillCommand.label"), + description: t("disableSkillCommand.description"), + }); + } return commands; - }, [onSpawn, onStartSideChat, slashPopover?.query, t]); + }, [onSpawn, onStartSideChat, skillsContext, slashPopover?.query, t]); const focusComposerSoon = React.useCallback(() => { window.requestAnimationFrame(() => composerRef.current?.focus()); @@ -225,7 +266,14 @@ export const TerminalAgentInputOverlay = React.forwardRef< return next; }); setMentionPopover(null); + if (slashPopoverViewRef.current === "disable_skills") { + composerRef.current?.beginSkillDisableChipDismiss(SKILL_DISABLE_DISMISS_SECONDS); + } setSlashPopover(null); + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); }, [focusComposerSoon, isSendAnimating, isSendExiting]); const togglePin = React.useCallback(() => { @@ -375,7 +423,8 @@ export const TerminalAgentInputOverlay = React.forwardRef< }); const selectSlashSkill = React.useCallback( - (skill: { path: string; name: string }) => { + (skill: { path: string; name: string; status?: string }) => { + if (skill.status === "disabled") return; const popover = slashPopover; if (!popover) return; composerRef.current?.applySlashAtRange( @@ -384,23 +433,69 @@ export const TerminalAgentInputOverlay = React.forwardRef< { kind: "skill", absolutePath: skill.path, name: skill.name }, ); setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); }, [slashPopover], ); - const { - activeIndex: activeSlashItemIndex, - expandedSections, - listRef: slashPopoverListRef, - setExpandedSections, - setItemRef: setSlashItemRef, - } = useWelcomeSlashNavigation({ - filteredAgents, - filteredCommands: slashCommands, - filteredProjects, - filteredSkills, - onSelectAgent: () => setSlashPopover(null), - onSelectCommand: (command) => { + const enterDisableSkillsView = React.useCallback(() => { + const popover = slashPopover; + if (!popover || !skillsContext) return; + suppressSlashCancelRef.current = true; + setSkillDisableSessionActions([]); + composerRef.current?.applySkillDisableCommandAtRange( + popover.slashOffset, + popover.query.length, + ); + slashPopoverViewRef.current = "disable_skills"; + setSlashPopoverView("disable_skills"); + setSkillDisableFilter(""); + void loadDisableSkills(); + window.requestAnimationFrame(() => { + composerRef.current?.focusSkillDisableFilter(); + suppressSlashCancelRef.current = false; + }); + }, [loadDisableSkills, skillsContext, slashPopover]); + + const backFromDisableSkills = React.useCallback(() => { + suppressSlashCancelRef.current = true; + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + composerRef.current?.restoreSlashFromSkillDisable(); + window.requestAnimationFrame(() => { + suppressSlashCancelRef.current = false; + }); + }, []); + + const toggleDisableSkill = React.useCallback( + async (skill: SkillInfo, enabled: boolean) => { + const beforeEnabled = skill.status !== "disabled"; + const ok = await setDisableSkillEnabled(skill, enabled); + if (!ok) return; + setSkillDisableSessionActions((current) => { + const next = upsertSkillDisableSessionAction( + current, + skill.id, + skill.title || skill.name, + beforeEnabled, + enabled, + ); + composerRef.current?.setSkillDisableSessionActions(next); + return next; + }); + }, + [setDisableSkillEnabled], + ); + + const selectSlashCommand = React.useCallback( + (command: SlashCommandOption) => { + if (command.id === "dynamic-skills") { + enterDisableSkillsView(); + return; + } if (command.id !== "side" && command.id !== "spawn") return; const popover = slashPopover; if (!popover) return; @@ -419,12 +514,68 @@ export const TerminalAgentInputOverlay = React.forwardRef< ); } setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + }, + [createCapturePromptContext, enterDisableSkillsView, slashPopover], + ); + + const { + activeIndex: activeSlashItemIndex, + expandedSections, + listRef: slashPopoverListRef, + setExpandedSections, + setItemRef: setSlashItemRef, + } = useWelcomeSlashNavigation({ + enabled: slashPopoverView === "menu", + filteredAgents, + filteredCommands: slashCommands, + filteredProjects, + filteredSkills, + onSelectAgent: () => { + setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + }, + onSelectCommand: selectSlashCommand, + onSelectProject: () => { + setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); }, - onSelectProject: () => setSlashPopover(null), onSelectSkill: selectSlashSkill, popover: slashPopover, }); + const closeSlashPopover = React.useCallback(() => { + if (slashPopoverViewRef.current === "disable_skills") { + composerRef.current?.beginSkillDisableChipDismiss(SKILL_DISABLE_DISMISS_SECONDS); + } + setSlashPopover(null); + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + setExpandedSections({ + skills: false, + projects: false, + agents: false, + }); + }, [setExpandedSections]); + + const handleSkillDisableSessionClosed = React.useCallback(() => { + setSlashPopover(null); + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + setExpandedSections({ + skills: false, + projects: false, + agents: false, + }); + }, [setExpandedSections]); + const handleTextChange = React.useCallback( (nextText: string) => { setText(nextText); @@ -618,7 +769,7 @@ export const TerminalAgentInputOverlay = React.forwardRef< ); const submit = React.useCallback(async () => { - const rawText = composerRef.current?.getText() ?? text; + const rawText = stripSkillDisableSession(composerRef.current?.getText() ?? text); if (!isTerminalReady || !rawText.trim() || isSending || isSendAnimating || isSendExiting) return; setIsSending(true); try { @@ -756,6 +907,7 @@ export const TerminalAgentInputOverlay = React.forwardRef< }, []); const handleSlashTrigger = React.useCallback((ctx: SlashTriggerContext) => { + if (slashPopoverViewRef.current === "disable_skills") return; const position = getTerminalAgentPopoverAboveCaret(ctx.caretRect); setMentionPopover(null); setSlashPopover({ @@ -764,6 +916,7 @@ export const TerminalAgentInputOverlay = React.forwardRef< slashOffset: ctx.slashOffset, query: ctx.query, }); + setSlashPopoverView("menu"); }, []); return ( @@ -811,32 +964,35 @@ export const TerminalAgentInputOverlay = React.forwardRef< onAtCancel={() => setMentionPopover(null)} onAtTrigger={handleAtTrigger} onPreviewAttachment={setPreviewAttachment} - onSlashCancel={() => setSlashPopover(null)} + onSlashCancel={() => { + if (suppressSlashCancelRef.current) return; + if (slashPopoverViewRef.current === "disable_skills") return; + closeSlashPopover(); + }} onSlashTrigger={handleSlashTrigger} + onSkillDisableFilterChange={setSkillDisableFilter} + onSkillDisableSessionClosed={handleSkillDisableSessionClosed} onSubmit={submit} placeholder={t("placeholder")} startSendExit={startSendExit} footerEndControl={ - skillsContext || shouldShowSideChatAgentSelector ? ( + shouldShowSideChatAgentSelector ? (
- {skillsContext ? : null} - {shouldShowSideChatAgentSelector ? ( -
- -
- ) : null} +
+ +
) : undefined } @@ -889,6 +1045,17 @@ export const TerminalAgentInputOverlay = React.forwardRef< setMentionPopover(null)} - onCloseSlash={() => setSlashPopover(null)} + onCloseSlash={closeSlashPopover} onSelectMentionFile={selectMentionFile} onSelectMentionNavItem={selectMentionNavItem} - onSelectSlashAgent={() => setSlashPopover(null)} - onSelectSlashCommand={(command) => { - if (command.id !== "side" && command.id !== "spawn") return; - const popover = slashPopover; - if (!popover) return; - const context = createCapturePromptContext(); - if (command.id === "spawn") { - composerRef.current?.applySpawnCommandAtRange( - popover.slashOffset, - popover.query.length, - context.contextId, - ); - } else { - composerRef.current?.applySideCommandAtRange( - popover.slashOffset, - popover.query.length, - context.contextId, - ); - } + onSelectSlashAgent={() => { setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + }} + onSelectSlashCommand={selectSlashCommand} + onSelectSlashProject={() => { + setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); }} - onSelectSlashProject={() => setSlashPopover(null)} onSelectSlashSkill={selectSlashSkill} + onToggleDisableSkill={(skill, enabled) => { + void toggleDisableSkill(skill, enabled); + }} onClosePreviewAttachment={() => setPreviewAttachment(null)} previewAttachment={previewAttachment} setExpandedSections={setExpandedSections} @@ -933,6 +1093,7 @@ export const TerminalAgentInputOverlay = React.forwardRef< setSlashItemRef={setSlashItemRef} slashPopover={slashPopover} slashPopoverListRef={slashPopoverListRef} + slashPopoverView={slashPopoverView} /> ["expandedSections"]; filteredAgents: React.ComponentProps["filteredAgents"]; filteredCommands?: SlashCommandOption[]; @@ -58,6 +68,7 @@ export function TerminalAgentInputPopovers({ mentionFiles: React.ComponentProps["mentionFiles"]; mentionPopover: MentionPopoverState; mentionPopoverListRef: React.ComponentProps["listRef"]; + onBackFromDisableSkills?: () => void; onCloseMention: () => void; onCloseSlash: () => void; onSelectMentionFile: (item: { relativePath: string }) => void; @@ -66,6 +77,7 @@ export function TerminalAgentInputPopovers({ onSelectSlashCommand?: (command: SlashCommandOption) => void; onSelectSlashProject: () => void; onSelectSlashSkill: React.ComponentProps["onSelectSkill"]; + onToggleDisableSkill?: (skill: SkillInfo, enabled: boolean) => void; onClosePreviewAttachment: () => void; previewAttachment: ImagePreviewAttachment | null; setExpandedSections: React.ComponentProps["setExpandedSections"]; @@ -73,6 +85,7 @@ export function TerminalAgentInputPopovers({ setSlashItemRef: React.ComponentProps["setItemRef"]; slashPopover: WelcomeSlashPopoverState; slashPopoverListRef: React.ComponentProps["listRef"]; + slashPopoverView?: SlashPopoverView; }) { return ( <> @@ -91,6 +104,7 @@ export function TerminalAgentInputPopovers({ /> {previewAttachment ? ( void; onSlashTrigger: (ctx: SlashTriggerContext) => void; + onSkillDisableFilterChange?: (filter: string) => void; + onSkillDisableSessionClosed?: () => void; onSubmit: () => void; placeholder: string; startSendExit: () => void; @@ -102,6 +106,8 @@ export function TerminalAgentInputShell({ onAtCancel={onAtCancel} onSlashTrigger={onSlashTrigger} onSlashCancel={onSlashCancel} + onSkillDisableFilterChange={onSkillDisableFilterChange} + onSkillDisableSessionClosed={onSkillDisableSessionClosed} /> - ))} - - ) : null} - + const handleBackdrop = () => { + onClose(); + }; + const menuContent = ( +
+ {showCommands && visibleCommands.length > 0 ? ( + <> +
+ {t("slashPopover.commands")} +
+ {visibleCommands.map((command, index) => ( + + ))} + + ) : null} - {showSkills ? ( - <> -
{t("slashPopover.skills")}
- {isSkillsLoading ? ( -
- - {t("slashPopover.loadingSkills")} -
- ) : filteredSkills.length > 0 ? ( - <> - {visibleSkills.map((skill, index) => ( + {showSkills ? ( + <> +
+ {t("slashPopover.skills")} +
+ {isSkillsLoading ? ( +
+ + {t("slashPopover.loadingSkills")} +
+ ) : filteredSkills.length > 0 ? ( + <> + {visibleSkills.map((skill, index) => { + const isDisabled = skill.status === "disabled"; + const navIndex = skillsStartIndex + index; + return ( - ))} - {filteredSkills.length > 3 && !expandedSections.skills ? ( + ); + })} + {filteredSkills.length > 3 && !expandedSections.skills ? ( + + ) : null} + + ) : ( +
+ {t("slashPopover.noSkillsAvailable")} +
+ )} + + ) : null} + + {showProjects ? ( + <> +
+ {t("slashPopover.projects")} +
+ {filteredProjects.length > 0 ? ( + <> + {visibleProjects.map((project, index) => { + const navIndex = projectsStartIndex + index; + return ( - ) : null} - - ) : ( -
{t("slashPopover.noSkillsAvailable")}
- )} - - ) : null} - - + ); + })} + {filteredProjects.length > 3 && !expandedSections.projects ? ( + + ) : null} + + ) : ( +
+ {t("slashPopover.noProjectsAvailable")} +
+ )} + + ) : null} - {showProjects ? ( - <> -
{t("slashPopover.projects")}
- {filteredProjects.length > 0 ? ( - <> - {visibleProjects.map((project, index) => { - const navIndex = projectsStartIndex + index; - return ( - - ); - })} - {filteredProjects.length > 3 && !expandedSections.projects ? ( + {showAgents ? ( + <> +
+ {t("slashPopover.codeAgents")} +
+ {filteredAgents.length > 0 ? ( + <> + {visibleAgents.map((agent, index) => { + const navIndex = agentsStartIndex + index; + return ( - ) : null} - - ) : ( -
{t("slashPopover.noProjectsAvailable")}
- )} - - ) : null} - + ); + })} + {filteredAgents.length > 3 && !expandedSections.agents ? ( + + ) : null} + + ) : ( +
+ {t("slashPopover.noAgentsAvailable")} +
+ )} + + ) : null} + {showProjects || showAgents ? ( +
+ {t("slashPopover.hidden")} + + {t("slashPopover.hiddenSummary", { + skills: showSkills + ? Math.max( + 0, + filteredSkills.length - (expandedSections.skills ? filteredSkills.length : 3), + ) + : 0, + projects: showProjects + ? Math.max( + 0, + filteredProjects.length - + (expandedSections.projects ? filteredProjects.length : 3), + ) + : 0, + agents: showAgents + ? Math.max( + 0, + filteredAgents.length - (expandedSections.agents ? filteredAgents.length : 3), + ) + : 0, + })} + +
+ ) : null} +
+ ); - {showAgents ? ( - <> -
{t("slashPopover.codeAgents")}
- {filteredAgents.length > 0 ? ( - <> - {visibleAgents.map((agent, index) => { - const navIndex = agentsStartIndex + index; - return ( - - ); - })} - {filteredAgents.length > 3 && !expandedSections.agents ? ( - - ) : null} - - ) : ( -
{t("slashPopover.noAgentsAvailable")}
- )} - + const disableContent = ( +
+
+ +

+ {disableT("title")} +

+
+
+ {disableSkills?.loading ? ( +
+ + {disableT("loading")} +
+ ) : disableList.length === 0 ? ( +
{disableT("empty")}
+ ) : ( + disableList.map((skill, index) => { + const enabled = skill.status !== "disabled"; + const busy = disableSkills?.pendingId === skill.id; + const isActive = index === disableActiveIndex; + return ( +
{ + disableItemRefs.current[index] = el; + }} + role="option" + aria-selected={isActive} + className={cn( + "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5", + isActive ? "bg-muted" : "hover:bg-muted/60", + )} + onMouseEnter={() => setDisableActiveIndex(index)} + onMouseDown={(event) => { + event.preventDefault(); + if (busy) return; + onToggleDisableSkill?.(skill, !enabled); + }} + > + + + {skill.title || skill.name} + + + {scopeBadgeLabel(skill.scope, t)} + + { + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + onCheckedChange={(checked) => { + onToggleDisableSkill?.(skill, checked); + }} + aria-label={ + enabled + ? disableT("disableSkill", { name: skill.name }) + : disableT("enableSkill", { name: skill.name }) + } + /> +
+ ); + }) + )} + {disableSkills?.error ? ( +

{disableSkills.error}

) : null} +
+
+ ); - {showProjects || showAgents ? ( - <> -
- {t("slashPopover.hidden")} - - {t("slashPopover.hiddenSummary", { - skills: showSkills - ? Math.max(0, filteredSkills.length - (expandedSections.skills ? filteredSkills.length : 3)) - : 0, - projects: showProjects - ? Math.max( - 0, - filteredProjects.length - (expandedSections.projects ? filteredProjects.length : 3), - ) - : 0, - agents: showAgents - ? Math.max(0, filteredAgents.length - (expandedSections.agents ? filteredAgents.length : 3)) - : 0, - })} - -
- - ) : null} + return createPortal( + <> +
+
+
+
+ {menuContent} +
+
+ {disableContent} +
+
, document.body, diff --git a/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx b/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx index a5d4812e5..accb7ca68 100644 --- a/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx +++ b/apps/web/src/features/welcome/components/WelcomeComposerCard.tsx @@ -34,6 +34,8 @@ export function WelcomeComposerCard({ onProjectChange, onSlashCancel, onSlashTrigger, + onSkillDisableFilterChange, + onSkillDisableSessionClosed, onTextChange, placeholder, priority, @@ -65,6 +67,8 @@ export function WelcomeComposerCard({ onProjectChange?: (projectId: string) => void; onSlashCancel?: () => void; onSlashTrigger?: (ctx: SlashTriggerContext) => void; + onSkillDisableFilterChange?: (filter: string) => void; + onSkillDisableSessionClosed?: () => void; onTextChange: (text: string) => void; placeholder: React.ReactNode; priority?: WorkspacePriority; @@ -101,6 +105,8 @@ export function WelcomeComposerCard({ onAtCancel={onAtCancel} onSlashTrigger={onSlashTrigger} onSlashCancel={onSlashCancel} + onSkillDisableFilterChange={onSkillDisableFilterChange} + onSkillDisableSessionClosed={onSkillDisableSessionClosed} /> = ({ } = useWelcomeComposerAttachments(composerRef); const [mentionPopover, setMentionPopover] = React.useState(null); const [slashPopover, setSlashPopover] = React.useState(null); + const [slashPopoverView, setSlashPopoverView] = React.useState("menu"); + const [skillDisableFilter, setSkillDisableFilter] = React.useState(""); + const [skillDisableSessionActions, setSkillDisableSessionActions] = React.useState< + SkillDisableSessionAction[] + >([]); + const suppressSlashCancelRef = React.useRef(false); + const slashPopoverViewRef = React.useRef("menu"); + slashPopoverViewRef.current = slashPopoverView; const [name, setName] = React.useState(""); const [branch, setBranch] = React.useState(""); const [submitError, setSubmitError] = React.useState(null); @@ -204,6 +223,46 @@ const WelcomePage: React.FC = ({ ); const selectedProjectId = selectedProject?.id ?? null; const selectedProjectPath = selectedProject?.mainFilePath ?? null; + const skillsContext = React.useMemo(() => { + if (!selectedProject?.mainFilePath) return null; + return { + mode: "project", + id: selectedProject.id, + name: selectedProject.name, + path: selectedProject.mainFilePath, + }; + }, [selectedProject]); + const { + error: disableSkillsError, + loading: disableSkillsLoading, + loadSkills: loadDisableSkills, + pendingId: disableSkillsPendingId, + setEnabled: setDisableSkillEnabled, + skills: disableSkillsList, + } = useComposerDisableSkills(skillsContext); + + const slashCommands = React.useMemo(() => { + if (!skillsContext) return []; + const query = slashPopover?.query.trim().toLowerCase() ?? ""; + if ( + query && + !"dynamic-skills".includes(query) && + !"dynamic skills".includes(query) && + !"disable-skill".includes(query) && + !"disable skill".includes(query) && + !"disable".includes(query) && + !"skill".includes(query) + ) { + return []; + } + return [ + { + id: "dynamic-skills", + label: t("slashPopover.disableSkill.label"), + description: t("slashPopover.disableSkill.description"), + }, + ]; + }, [skillsContext, slashPopover?.query, t]); const { autoExtractTodos, autoExtractTodosPr, @@ -335,6 +394,7 @@ const WelcomePage: React.FC = ({ const selectSlashSkill = React.useCallback( (skill: SkillInfo) => { + if (skill.status === "disabled") return; const popover = slashPopover; if (!popover) return; composerRef.current?.applySlashAtRange( @@ -343,6 +403,8 @@ const WelcomePage: React.FC = ({ { kind: "skill", absolutePath: skill.path, name: skill.name }, ); setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); }, [slashPopover], ); @@ -353,6 +415,8 @@ const WelcomePage: React.FC = ({ if (!popover) return; // Close popover immediately to prevent re-opening from side effects setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); composerRef.current?.removeSlashAtRange( popover.slashOffset, popover.query.length, @@ -369,6 +433,8 @@ const WelcomePage: React.FC = ({ if (!popover) return; // Close popover immediately to prevent re-opening from side effects setSlashPopover(null); + setSlashPopoverView("menu"); + setSkillDisableFilter(""); composerRef.current?.removeSlashAtRange( popover.slashOffset, popover.query.length, @@ -379,6 +445,66 @@ const WelcomePage: React.FC = ({ [slashPopover, setSelectedAgentId], ); + const enterDisableSkillsView = React.useCallback(() => { + const popover = slashPopover; + if (!popover || !skillsContext) return; + suppressSlashCancelRef.current = true; + setSkillDisableSessionActions([]); + composerRef.current?.applySkillDisableCommandAtRange( + popover.slashOffset, + popover.query.length, + ); + slashPopoverViewRef.current = "disable_skills"; + setSlashPopoverView("disable_skills"); + setSkillDisableFilter(""); + void loadDisableSkills(); + window.requestAnimationFrame(() => { + composerRef.current?.focusSkillDisableFilter(); + suppressSlashCancelRef.current = false; + }); + }, [loadDisableSkills, skillsContext, slashPopover]); + + const backFromDisableSkills = React.useCallback(() => { + suppressSlashCancelRef.current = true; + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + composerRef.current?.restoreSlashFromSkillDisable(); + window.requestAnimationFrame(() => { + suppressSlashCancelRef.current = false; + }); + }, []); + + const toggleDisableSkill = React.useCallback( + async (skill: SkillInfo, enabled: boolean) => { + const beforeEnabled = skill.status !== "disabled"; + const ok = await setDisableSkillEnabled(skill, enabled); + if (!ok) return; + setSkillDisableSessionActions((current) => { + const next = upsertSkillDisableSessionAction( + current, + skill.id, + skill.title || skill.name, + beforeEnabled, + enabled, + ); + composerRef.current?.setSkillDisableSessionActions(next); + return next; + }); + }, + [setDisableSkillEnabled], + ); + + const selectSlashCommand = React.useCallback( + (command: SlashCommandOption) => { + if (command.id === "dynamic-skills") { + enterDisableSkillsView(); + } + }, + [enterDisableSkillsView], + ); + const { activeIndex: activeSlashItemIndex, expandedSections, @@ -386,15 +512,47 @@ const WelcomePage: React.FC = ({ setExpandedSections, setItemRef: setSlashItemRef, } = useWelcomeSlashNavigation({ + enabled: slashPopoverView === "menu", filteredAgents, + filteredCommands: slashCommands, filteredProjects, filteredSkills, onSelectAgent: selectSlashAgent, + onSelectCommand: selectSlashCommand, onSelectProject: selectSlashProject, onSelectSkill: selectSlashSkill, popover: slashPopover, }); + const closeSlashPopover = React.useCallback(() => { + if (slashPopoverViewRef.current === "disable_skills") { + composerRef.current?.beginSkillDisableChipDismiss(SKILL_DISABLE_DISMISS_SECONDS); + } + setSlashPopover(null); + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + setExpandedSections({ + skills: false, + projects: false, + agents: false, + }); + }, [setExpandedSections]); + + const handleSkillDisableSessionClosed = React.useCallback(() => { + setSlashPopover(null); + slashPopoverViewRef.current = "menu"; + setSlashPopoverView("menu"); + setSkillDisableFilter(""); + setSkillDisableSessionActions([]); + setExpandedSections({ + skills: false, + projects: false, + agents: false, + }); + }, [setExpandedSections]); + React.useEffect(() => { let cancelled = false; @@ -542,7 +700,9 @@ const WelcomePage: React.FC = ({ } } - const rawPrompt = composerRef.current?.getText() ?? initialRequirement; + const rawPrompt = stripSkillDisableSession( + composerRef.current?.getText() ?? initialRequirement, + ); const resolvedPrompt = resolvePromptPlaceholders(rawPrompt, attachments); const attachmentPayload = await Promise.all( attachments.map(async (a) => ({ @@ -725,19 +885,37 @@ const WelcomePage: React.FC = ({ previewAttachment={previewAttachment} slashPopoverProps={{ activeIndex: activeSlashItemIndex, + disableSkills: + slashPopoverView === "disable_skills" + ? { + filter: skillDisableFilter, + loading: disableSkillsLoading, + pendingId: disableSkillsPendingId, + skills: disableSkillsList, + error: disableSkillsError, + } + : null, expandedSections, filteredAgents, + filteredCommands: slashCommands, filteredProjects, filteredSkills, isSkillsLoading, listRef: slashPopoverListRef, - onClose: () => setSlashPopover(null), + onBackFromDisableSkills: backFromDisableSkills, + onClose: closeSlashPopover, onSelectAgent: selectSlashAgent, + onSelectCommand: selectSlashCommand, onSelectProject: selectSlashProject, onSelectSkill: selectSlashSkill, + onToggleDisableSkill: (skill, enabled) => { + void toggleDisableSkill(skill, enabled); + }, popover: slashPopover, setExpandedSections, setItemRef: setSlashItemRef, + showCommands: slashCommands.length > 0, + view: slashPopoverView, }} summaryItems={filledSummaryItems} onPreviewAttachmentClose={() => setPreviewAttachment(null)} @@ -810,21 +988,25 @@ const WelcomePage: React.FC = ({ onImagePaste={handleImagePaste} onProjectChange={setProjectId} onSlashCancel={() => { - setSlashPopover(null); - setExpandedSections({ - skills: false, - projects: false, - agents: false, - }); + if (suppressSlashCancelRef.current) return; + if (slashPopoverViewRef.current === "disable_skills") { + // Chip filter still owns focus; keep the disable session open. + return; + } + closeSlashPopover(); }} onSlashTrigger={(ctx) => { + if (slashPopoverViewRef.current === "disable_skills") return; setSlashPopover({ top: ctx.caretRect.bottom + 4, left: ctx.caretRect.left, slashOffset: ctx.slashOffset, query: ctx.query, }); + setSlashPopoverView("menu"); }} + onSkillDisableFilterChange={setSkillDisableFilter} + onSkillDisableSessionClosed={handleSkillDisableSessionClosed} onTextChange={(text) => { setInitialRequirement(text); setSubmitError(null); @@ -853,18 +1035,6 @@ const WelcomePage: React.FC = ({ setPriority={setPriority} setSelectedLabels={setSelectedLabels} setWorkflowStatus={setWorkflowStatus} - skillsControl={ - selectedProject?.mainFilePath ? ( - - ) : null - } workflowStatus={workflowStatus} workspaceLabels={workspaceLabels} footer={composerFooter} diff --git a/apps/web/src/features/welcome/hooks/use-welcome-slash-navigation.ts b/apps/web/src/features/welcome/hooks/use-welcome-slash-navigation.ts index cc8ef1d02..36a855bc9 100644 --- a/apps/web/src/features/welcome/hooks/use-welcome-slash-navigation.ts +++ b/apps/web/src/features/welcome/hooks/use-welcome-slash-navigation.ts @@ -28,6 +28,8 @@ type SlashNavigationItem = | { type: "show-more"; section: SlashSection }; interface UseWelcomeSlashNavigationArgs { + /** When false, skip arrow/enter handling (e.g. disable-skills morph view). */ + enabled?: boolean; filteredAgents: AgentMenuOption[]; filteredCommands?: SlashCommandOption[]; filteredProjects: Project[]; @@ -40,6 +42,7 @@ interface UseWelcomeSlashNavigationArgs { } export function useWelcomeSlashNavigation({ + enabled = true, filteredAgents, filteredCommands = [], filteredProjects, @@ -116,7 +119,7 @@ export function useWelcomeSlashNavigation({ }, [activeIndex, popover]); React.useEffect(() => { - if (!popover) return; + if (!popover || !enabled) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown") { if (visibleItems.length === 0) return; @@ -137,6 +140,7 @@ export function useWelcomeSlashNavigation({ if (item.type === "command") { onSelectCommand?.(item.item); } else if (item.type === "skill") { + if (item.item.status === "disabled") return; onSelectSkill(item.item); } else if (item.type === "project") { onSelectProject(item.item); @@ -148,7 +152,16 @@ export function useWelcomeSlashNavigation({ }; document.addEventListener("keydown", handleKeyDown, { capture: true }); return () => document.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [activeIndex, onSelectAgent, onSelectCommand, onSelectProject, onSelectSkill, popover, visibleItems]); + }, [ + activeIndex, + enabled, + onSelectAgent, + onSelectCommand, + onSelectProject, + onSelectSkill, + popover, + visibleItems, + ]); return { activeIndex, diff --git a/apps/web/src/features/welcome/hooks/use-welcome-slash-search.ts b/apps/web/src/features/welcome/hooks/use-welcome-slash-search.ts index 35f0b3be8..278fcb5a9 100644 --- a/apps/web/src/features/welcome/hooks/use-welcome-slash-search.ts +++ b/apps/web/src/features/welcome/hooks/use-welcome-slash-search.ts @@ -2,10 +2,8 @@ import React from "react"; import Fuse from "fuse.js"; -import { - skillsApi, - type SkillInfo, -} from "@/api/ws-api"; +import type { SkillInfo } from "@/api/ws-api"; +import { useSkillsListQuery } from "@/features/skills/hooks/use-skills-query"; import type { WelcomeSlashPopoverState } from "@/features/welcome/hooks/use-welcome-slash-navigation"; import { useDebouncedPopoverQuery, @@ -14,6 +12,14 @@ import { import { filterSlashSkillsForProject } from "@/features/welcome/lib/slash-skill-context"; import type { Project } from "@/shared/types/domain"; +function isSlashSurfacedSkill(skill: SkillInfo) { + return ( + skill.scope === "global" || + skill.scope === "project" || + skill.scope === "inside_project" + ); +} + export function useWelcomeSlashSearch({ availableAgents, activeProjectId, @@ -26,32 +32,12 @@ export function useWelcomeSlashSearch({ projects: Project[]; }) { const debouncedSlashQuery = useDebouncedPopoverQuery(popover, 300); - const [skills, setSkills] = React.useState([]); - const [isSkillsLoading, setIsSkillsLoading] = React.useState(false); - - React.useEffect(() => { - const loadSkills = async () => { - setIsSkillsLoading(true); - try { - const response = await skillsApi.list({ forceRefresh: false }); - // Only keep skills that can be surfaced from slash commands. - const filteredSkills = response.skills.filter( - (skill) => - skill.scope === "global" || - skill.scope === "project" || - skill.scope === "inside_project", - ); - setSkills(filteredSkills); - } catch (error) { - console.error("Failed to load skills:", error); - setSkills([]); - } finally { - setIsSkillsLoading(false); - } - }; + const skillsQuery = useSkillsListQuery(); - void loadSkills(); - }, []); + const skills = React.useMemo( + () => (skillsQuery.data?.skills ?? []).filter(isSlashSurfacedSkill), + [skillsQuery.data?.skills], + ); const visibleSkills = React.useMemo( () => filterSlashSkillsForProject(skills, activeProjectId ?? null), @@ -113,6 +99,6 @@ export function useWelcomeSlashSearch({ filteredAgents, filteredProjects, filteredSkills, - isSkillsLoading, + isSkillsLoading: skillsQuery.isPending && !skillsQuery.data, }; } diff --git a/apps/web/src/features/welcome/lib/slash-skill-context.ts b/apps/web/src/features/welcome/lib/slash-skill-context.ts index 1b43a20fa..e9324e5a9 100644 --- a/apps/web/src/features/welcome/lib/slash-skill-context.ts +++ b/apps/web/src/features/welcome/lib/slash-skill-context.ts @@ -1,12 +1,12 @@ export type SlashSkillContextItem = { - scope: "global" | "project" | "inside_project" | "system"; + scope: "global" | "project" | "workspace" | "inside_project" | "system"; project_id: string | null; }; export function filterSlashSkillsForProject( skills: T[], activeProjectId: string | null, -) { +): T[] { return skills.filter((skill) => { if (skill.scope === "global") return true; if (skill.scope !== "project" && skill.scope !== "inside_project") { diff --git a/crates/core-service/src/service/skill.rs b/crates/core-service/src/service/skill.rs index 163d1b5c3..b9151c949 100644 --- a/crates/core-service/src/service/skill.rs +++ b/crates/core-service/src/service/skill.rs @@ -18,9 +18,23 @@ pub use self::types::ScanMode; use crate::error::{Result, ServiceError}; use crate::{SkillInfo, SkillPlacement}; use std::collections::HashSet; +use std::fs; use std::path::{Path, PathBuf}; const DISABLED_STORAGE_REL_PATH: &str = ".atmos/skills/.disabled"; +const SKILL_DISABLED_MARKER: &str = "SKILL_DISABLED.md"; + +const SKILL_DISABLED_MARKER_CONTENT: &str = r#"--- +atmos_skill_disabled: true +--- + +# Skill disabled + +This skill has been disabled by the user in Atmos. + +The original skill files were moved to Atmos disabled storage. +Do not use this skill. Do not search for or restore the original skill content from other paths. +"#; pub struct SkillScanner; @@ -80,7 +94,17 @@ impl SkillManager { ensure_entry_local_to_root(&scope_root, &from)?; } - move_entry_without_following_symlink(&from, &to)?; + if enabled { + // Clear the live-path marker so restore can recreate the skill dir. + Self::remove_skill_disabled_marker(&to)?; + move_entry_without_following_symlink(&from, &to)?; + } else { + let marker_dir = PathBuf::from(&placement.original_path); + move_entry_without_following_symlink(&from, &to)?; + // Leave a non-SKILL.md marker so Agents that already know this path + // hit a dead entrypoint, while new scans skip the directory. + Self::write_skill_disabled_marker(&marker_dir)?; + } } ensure_selection_applied(&skill, &selected_placement_ids, |placement| { @@ -198,6 +222,65 @@ impl SkillManager { Ok(scope_root.join(DISABLED_STORAGE_REL_PATH).join(relative)) } + + fn write_skill_disabled_marker(original_skill_dir: &Path) -> Result<()> { + if let Some(parent) = original_skill_dir.parent() { + fs::create_dir_all(parent).map_err(|e| { + ServiceError::Validation(format!( + "Failed to create skill marker parent '{}': {}", + parent.display(), + e + )) + })?; + } + fs::create_dir_all(original_skill_dir).map_err(|e| { + ServiceError::Validation(format!( + "Failed to create skill disabled marker directory '{}': {}", + original_skill_dir.display(), + e + )) + })?; + let marker_path = original_skill_dir.join(SKILL_DISABLED_MARKER); + fs::write(&marker_path, SKILL_DISABLED_MARKER_CONTENT).map_err(|e| { + ServiceError::Validation(format!( + "Failed to write skill disabled marker '{}': {}", + marker_path.display(), + e + )) + })?; + Ok(()) + } + + fn remove_skill_disabled_marker(original_skill_dir: &Path) -> Result<()> { + let marker_path = original_skill_dir.join(SKILL_DISABLED_MARKER); + if marker_path.is_file() { + fs::remove_file(&marker_path).map_err(|e| { + ServiceError::Validation(format!( + "Failed to remove skill disabled marker '{}': {}", + marker_path.display(), + e + )) + })?; + } + + // Remove the placeholder directory when it only held the marker (or is empty). + if original_skill_dir.is_dir() { + let is_empty = fs::read_dir(original_skill_dir) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false); + if is_empty { + fs::remove_dir(original_skill_dir).map_err(|e| { + ServiceError::Validation(format!( + "Failed to remove skill disabled marker directory '{}': {}", + original_skill_dir.display(), + e + )) + })?; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/crates/core-service/src/service/skill/scanner.rs b/crates/core-service/src/service/skill/scanner.rs index 80dc06157..9577ed078 100644 --- a/crates/core-service/src/service/skill/scanner.rs +++ b/crates/core-service/src/service/skill/scanner.rs @@ -1,7 +1,7 @@ use super::metadata::{extract_description, extract_from_frontmatter, strip_frontmatter}; use super::support::{build_placement_id, build_skill_id, is_manageable_scope}; use super::types::{ScanContext, ScanMode, ScanStatus, SkillEntryMeta}; -use super::{SkillScanner, DISABLED_STORAGE_REL_PATH}; +use super::{SkillScanner, DISABLED_STORAGE_REL_PATH, SKILL_DISABLED_MARKER}; use crate::{SkillFile, SkillInfo, SkillPlacement}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -339,7 +339,8 @@ impl SkillScanner { .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - if entry_name.starts_with('.') { + if entry_name.starts_with('.') || entry_name == SKILL_DISABLED_MARKER { + // Live-path disable marker must not become a single-file skill. continue; } diff --git a/crates/core-service/src/service/skill/support.rs b/crates/core-service/src/service/skill/support.rs index 9ad3820ba..1dadaab08 100644 --- a/crates/core-service/src/service/skill/support.rs +++ b/crates/core-service/src/service/skill/support.rs @@ -146,6 +146,8 @@ pub(super) fn move_entry_without_following_symlink(from: &Path, to: &Path) -> Re /// local symlink or a real local entry. pub(super) fn ensure_entry_local_to_root(root: &Path, path: &Path) -> Result<()> { let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let path = normalize_mac_private_prefix(path); + let root = normalize_mac_private_prefix(&root); let mut guard = 0usize; loop { @@ -156,7 +158,7 @@ pub(super) fn ensure_entry_local_to_root(root: &Path, path: &Path) -> Result<()> )); } - let metadata = fs::symlink_metadata(path).map_err(|e| { + let metadata = fs::symlink_metadata(&path).map_err(|e| { ServiceError::Validation(format!( "Failed to inspect skill entry '{}': {}", path.display(), @@ -167,7 +169,7 @@ pub(super) fn ensure_entry_local_to_root(root: &Path, path: &Path) -> Result<()> return Ok(()); } - let Some(ancestor) = find_symlink_ancestor(&root, path)? else { + let Some(ancestor) = find_symlink_ancestor(&root, &path)? else { return Ok(()); }; @@ -175,15 +177,29 @@ pub(super) fn ensure_entry_local_to_root(root: &Path, path: &Path) -> Result<()> } } +/// macOS often exposes the same directory as both `/var/...` and `/private/var/...`. +/// `canonicalize(root)` yields the `/private` form while skill paths from scans may not, +/// which breaks `starts_with` checks and skips parent-symlink materialization. +fn normalize_mac_private_prefix(path: &Path) -> PathBuf { + let raw = path.to_string_lossy(); + if let Some(stripped) = raw.strip_prefix("/private") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + fn find_symlink_ancestor(root: &Path, path: &Path) -> Result> { let mut current = path.parent().map(Path::to_path_buf); let mut found = None; while let Some(candidate) = current { - if candidate == *root { + let candidate_norm = normalize_mac_private_prefix(&candidate); + let root_norm = normalize_mac_private_prefix(root); + if candidate_norm == root_norm { break; } - if !candidate.starts_with(root) { + if !candidate_norm.starts_with(&root_norm) { break; } diff --git a/crates/core-service/src/service/skill/tests.rs b/crates/core-service/src/service/skill/tests.rs index b2ad948de..d62cbed1b 100644 --- a/crates/core-service/src/service/skill/tests.rs +++ b/crates/core-service/src/service/skill/tests.rs @@ -245,7 +245,16 @@ fn project_disable_moves_skill_into_disabled_storage() { SkillManager::set_enabled(&project_paths, skill_id, false, None).expect("disable"); - assert!(!project.join(".claude/skills/demo").exists()); + assert!( + project + .join(".claude/skills/demo/SKILL_DISABLED.md") + .exists(), + "live path should keep a non-SKILL.md disabled marker" + ); + assert!( + !project.join(".claude/skills/demo/SKILL.md").exists(), + "live path must not keep SKILL.md after disable" + ); assert!(project .join(".atmos/skills/.disabled/.claude/skills/demo/SKILL.md") .exists()); @@ -253,10 +262,21 @@ fn project_disable_moves_skill_into_disabled_storage() { let skills = SkillScanner::scan_all(&project_paths); let skill = skills.iter().find(|s| s.id == skill_id).expect("skill"); assert_eq!(skill.status, "disabled"); + assert!( + skills.iter().all(|s| s.name != "SKILL_DISABLED"), + "live-path SKILL_DISABLED.md must not appear as a skill, got {:?}", + skills.iter().map(|s| &s.name).collect::>(), + ); // Re-enable restores original path. SkillManager::set_enabled(&project_paths, skill_id, true, None).expect("enable"); assert!(project.join(".claude/skills/demo/SKILL.md").exists()); + assert!( + !project + .join(".claude/skills/demo/SKILL_DISABLED.md") + .exists(), + "disabled marker must be removed on enable" + ); } #[test] @@ -286,7 +306,16 @@ fn workspace_copy_disable_is_local_to_workplace() { project.join(".claude/skills/demo/SKILL.md").exists(), "project copy must stay" ); - assert!(!workplace.join(".claude/skills/demo").exists()); + assert!( + workplace + .join(".claude/skills/demo/SKILL_DISABLED.md") + .exists(), + "workplace live path should keep disabled marker" + ); + assert!( + !workplace.join(".claude/skills/demo/SKILL.md").exists(), + "workplace must not keep SKILL.md after disable" + ); assert!(workplace .join(".atmos/skills/.disabled/.claude/skills/demo/SKILL.md") .exists()); @@ -294,6 +323,11 @@ fn workspace_copy_disable_is_local_to_workplace() { let skills = SkillScanner::scan_root(&root, ScanMode::Lazy); let skill = skills.iter().find(|s| s.id == skill_id).expect("skill"); assert_eq!(skill.status, "disabled"); + assert!( + skills.iter().all(|s| s.name != "SKILL_DISABLED"), + "live-path SKILL_DISABLED.md must not appear as a skill, got {:?}", + skills.iter().map(|s| &s.name).collect::>(), + ); } #[cfg(unix)] @@ -332,9 +366,21 @@ fn workspace_parent_symlink_disable_does_not_mutate_project() { "sibling project skill must remain" ); assert!( - !workplace.join(".claude/skills/demo").exists(), + !workplace.join(".claude/skills/demo/SKILL.md").exists(), "workplace must no longer expose enabled demo skill" ); + assert!( + workplace + .join(".claude/skills/demo/SKILL_DISABLED.md") + .exists(), + "workplace live path should keep disabled marker" + ); + assert!( + !project + .join(".claude/skills/demo/SKILL_DISABLED.md") + .exists(), + "project must not receive the workplace disabled marker" + ); assert!( workplace .join(".atmos/skills/.disabled/.claude/skills/demo") @@ -352,4 +398,9 @@ fn workspace_parent_symlink_disable_does_not_mutate_project() { .find(|s| s.id == "workspace::ws-1::keep") .expect("keep"); assert_eq!(keep.status, "enabled"); + assert!( + skills.iter().all(|s| s.name != "SKILL_DISABLED"), + "live-path SKILL_DISABLED.md must not appear as a skill, got {:?}", + skills.iter().map(|s| &s.name).collect::>(), + ); } diff --git a/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md index 850b75aa7..107ab47bd 100644 --- a/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md +++ b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md @@ -17,8 +17,9 @@ Add a dynamic enable/disable control for skills directly in: Behavior must match the Skills management page’s move-to-`.atmos/skills/.disabled` mechanism for Project, and reuse that same move for Workspace with the disabled storage -rooted at the **current workplace directory**. Tip the user that an already-initialized -Agent session has already baked skills into the system prompt. +rooted at the **current workplace directory**. Because Atmos does not own Agent Runtime, +mid-session effect relies on filesystem entrypoint removal (no `SKILL.md` at the live +path) plus an optional live-path marker file Agents will not treat as a skill. ## Options @@ -41,13 +42,37 @@ Keep files on disk; strip skill names from Atmos-owned prompts. **Pros**: no filesystem mutation. **Cons**: does not stop terminal Agents that load skills themselves; diverges from Skills page. +### Option C — Live-path `SKILL.md` stub / tombstone (rejected as permanent stub) + +Leave a real `SKILL.md` at the original path with “disabled” copy so current sessions +that re-read the file see a ban message. + +**Pros**: current-session Read path may see explicit ban text. +**Cons**: next Agent scan still discovers the skill (pollutes discovery). Permanent stub +is not worth it. + +### Option D — Move to `.disabled` + live-path `SKILL_DISABLED.md` marker (chosen add-on) + +After moving the full skill tree into `.disabled`, recreate the original skill directory +with only `SKILL_DISABLED.md` (never `SKILL.md`). + +**Pros**: new sessions do not discover the skill (no `SKILL.md`); current sessions that +try to open `SKILL.md` get not-found; marker is human/Atmos-readable; no Agent Runtime +hooks required. +**Cons**: not a hard Runtime gate; models that ignore missing entrypoints or read other +cached copies can still misbehave. + ## Decision -Option A. +Option A + Option D. ## Open questions (resolved) - Disabled storage path → reuse `.atmos/skills/.disabled` under the active root (project root or workplace root), not a separate `.disable` name. - Symlink vs copy → same move API after optional ancestor materialization. -- Session tip → always shown in the composer skills popover. +- Mid-session tip → describe dynamic visibility / current-session entrypoint effect; do + **not** claim “new session only”. +- Live marker filename → `SKILL_DISABLED.md` (not `SKILL.md`, not `DISABLE_SKILL.md`). +- Composer entry → `/` slash command **Dynamic Skills** (morph popover), not a standalone + Skills footer button. diff --git a/specs/APP/APP-040_composer-skills-disable/PRD.md b/specs/APP/APP-040_composer-skills-disable/PRD.md index 5cb08ea78..d97bbbea5 100644 --- a/specs/APP/APP-040_composer-skills-disable/PRD.md +++ b/specs/APP/APP-040_composer-skills-disable/PRD.md @@ -2,16 +2,18 @@ ## Summary -Users can enable/disable Agent skills from PromptComposer and the terminal AI Input, -using the same filesystem disable mechanism as the Skills management page. Project -toggles stay in sync with that page. Workspace toggles apply only under the current -workplace directory (where sync dirs land), so Agents in that worktree stop discovering -the skill. The UI warns that changes apply to **new** Agent sessions only. +Users can enable/disable Agent skills from PromptComposer and the terminal AI Input +via a `/` slash command (**Dynamic Skills**), using the same filesystem disable +mechanism as the Skills management page, plus a live-path `SKILL_DISABLED.md` marker. +Project toggles stay in sync with that page. Workspace toggles apply only under the +current workplace directory (where sync dirs land). Toggles change Agent-visible skill +entrypoints immediately: the real skill tree leaves discovery paths, and the live path +no longer has `SKILL.md`. ## Users & jobs - **Project operator** on Welcome / PromptComposer: quickly mute noisy project skills - before launching a workspace Agent, without opening Skills. + before or while working with Agents, without opening Skills. - **Workspace operator** in a terminal AI Input: mute skills that were synced into the worktree (symlink or copy) for this workplace only. @@ -19,29 +21,41 @@ the skill. The UI warns that changes apply to **new** Agent sessions only. | ID | Requirement | |----|-------------| -| M1 | PromptComposer exposes a skills enable/disable control for manageable skills visible in the current project context. | -| M2 | Terminal AI Input exposes the same control. In a project terminal it uses project disable; in a workspace terminal it uses workplace-rooted disable. | -| M3 | Project disable/enable uses the existing Skills Manager move into `project/.atmos/skills/.disabled/...` and stays consistent with the Skills management page. | -| M4 | Workspace disable/enable reuses the same move logic with storage under `workplace/.atmos/skills/.disabled/...`. | -| M5 | Sync-dir symlink and copy placements can both be disabled by moving the workplace-visible skill entry into `.disabled` so Agents no longer discover it. Parent compensated symlinks are materialized as needed so the move does not mutate the project tree. | -| M6 | UI copy warns that disable takes effect on a **new** Agent session; already-initialized sessions keep skills already loaded into the system prompt. | +| M1 | PromptComposer exposes Dynamic Skills via the `/` slash command for manageable skills visible in the current project context. | +| M2 | Terminal AI Input exposes the same `/` Dynamic Skills command. In a project terminal it uses project disable; in a workspace terminal it uses workplace-rooted disable. | +| M3 | Project disable/enable uses Skills Manager move into `project/.atmos/skills/.disabled/...`, stays consistent with the Skills management page, and leaves `SKILL_DISABLED.md` at the original skill directory while disabled. | +| M4 | Workspace disable/enable reuses the same move logic with storage under `workplace/.atmos/skills/.disabled/...`, and the same live-path `SKILL_DISABLED.md` marker under the workplace. | +| M5 | Sync-dir symlink and copy placements can both be disabled by moving the workplace-visible skill entry into `.disabled` so Agents no longer discover `SKILL.md`. Parent compensated symlinks are materialized as needed so the move does not mutate the project tree. | +| M6 | UI copy describes **dynamic skill visibility** that takes effect for the **current session** (entrypoint / discovery), not “new session only”. Tip may still note that Agents do not provide a Runtime API and residual in-memory catalogs are best-effort. | | M7 | Global skills remain toggleable where `can_toggle` is true (same as Skills page). System / InsideTheProject skills stay non-toggleable. | +| M8 | Enable removes the live-path `SKILL_DISABLED.md` marker (and empty placeholder directory) before restoring the skill from `.disabled`. | ## Nice to Have | ID | Requirement | |----|-------------| -| N1 | Show disabled skills in the composer popover so they can be re-enabled without visiting Skills. | +| N1 | Show disabled skills in the Dynamic Skills popover so they can be re-enabled without visiting Skills. | | N2 | After a toggle, refresh slash-command skill lists that are already open. | +| N3 | Disabled skills may still appear in the `/` skill-insert list, but are not selectable and show a Disabled badge. | ## Success metrics - Project toggle from composer updates Skills page status after refresh without a second API. - Workspace toggle leaves project skill files untouched when the workplace entry was a compensated symlink/copy. -- Users see the new-session tip before or when toggling. +- After disable, live path has `SKILL_DISABLED.md` and no `SKILL.md`; after enable, `SKILL.md` is restored and the marker is gone. +- Users see Dynamic Skills copy that matches current-session entrypoint behavior. ## Out of scope -- Retroactively rewriting an already-running Agent session’s system prompt. +- Rewriting Agent Runtime internals or injecting per-Agent hooks to purge in-memory skill catalogs. +- Guaranteeing a model will never mention a skill whose metadata was already baked into an earlier prompt turn. - Cloud-synced disable state across machines. - Disabling skills inside the read-only `skills/` (InsideTheProject) tree. + +## Post-Implementation Update · 2026-07-22 + +- Entry UX moved from a standalone footer Skills control to `/` command **Dynamic Skills** + with a morphing disable popover. +- Filesystem contract extended: `.disabled` storage remains source of truth; live path + keeps `SKILL_DISABLED.md` only (never a `SKILL.md` stub). +- M6 reframed from “new session only” to “dynamic visibility / current-session entrypoint”. diff --git a/specs/APP/APP-040_composer-skills-disable/TECH.md b/specs/APP/APP-040_composer-skills-disable/TECH.md index 6195f76b2..48bcf54c5 100644 --- a/specs/APP/APP-040_composer-skills-disable/TECH.md +++ b/specs/APP/APP-040_composer-skills-disable/TECH.md @@ -4,37 +4,60 @@ ```text Composer / Terminal AI Input - └─ ComposerSkillsControl (shared UI) - ├─ Project context → skills_list (+ filter) → skills_set_enabled (existing) - └─ Workspace context → skills_scan_root(workplace) → skills_set_enabled(+ scope_root) - └─ core-service SkillManager - ├─ disabled_path = /.atmos/skills/.disabled/ - ├─ ensure_local_movable (workspace only; materialize compensated ancestors) - └─ move_entry_without_following_symlink + └─ `/` slash command "Dynamic Skills" + └─ SlashCommandPopover view=disable_skills + ├─ Project context → skills_list (+ filter) → skills_set_enabled + └─ Workspace context → skills_scan_root(workplace) → skills_set_enabled(+ scope_root) + └─ core-service SkillManager + ├─ move live skill tree → /.atmos/skills/.disabled/ + ├─ write /SKILL_DISABLED.md marker (no SKILL.md) + ├─ enable: remove marker dir, move back from .disabled + ├─ ensure_entry_local_to_root (workspace; materialize compensated ancestors) + └─ move_entry_without_following_symlink ``` ## Data / filesystem - Disabled storage relative path stays `DISABLED_STORAGE_REL_PATH = ".atmos/skills/.disabled"`. +- Live-path marker filename: `SKILL_DISABLED.md` (constant `SKILL_DISABLED_MARKER`). +- Marker content includes `atmos_skill_disabled: true` frontmatter plus human/Agent-facing + “do not use / do not search” instructions. Agents that only discover `SKILL.md` will + **not** treat the marker as a skill. - Project root = project `main_file_path`. - Workspace root = workspace `local_path` (git worktree). -- New manageable scope string: `"workspace"`. +- Manageable scope string: `"workspace"`. - Skill ids: `workspace::{workspace_id}::{name}` (same builder as project/global). +### Disable / enable sequence + +**Disable** + +1. Resolve placement path; for workspace, `ensure_entry_local_to_root` first. +2. `move_entry_without_following_symlink(live → .disabled/)` (entire tree: + `SKILL.md`, `references/`, `scripts/`, …). +3. Recreate `/` and write `SKILL_DISABLED.md` only. + +**Enable** + +1. Remove `/SKILL_DISABLED.md`; remove the directory if empty. +2. `move_entry_without_following_symlink(.disabled/ → original)`. + ## Backend (`crates/core-service`) 1. `is_manageable_scope` includes `"workspace"`. -2. `SkillScanner::scan_roots(roots, mode)` where each root is `(scope, id, name, path)`. - Existing `scan_all(project_paths)` remains a thin wrapper (global + system + projects). +2. `SkillScanner::scan_roots(roots, mode)` / `scan_all_with_extra_roots`; `scan_all(project_paths)` remains a thin wrapper. 3. `SkillManager::set_enabled` accepts optional extra roots (workspace) merged into path records. 4. `disabled_path_for` handles `scope == "workspace"` like `project` (lookup root by id). 5. Before a **workspace** disable/enable move, call `ensure_entry_local_to_root(root, path)`: - - If `path` (or the path being restored) is a symlink → no-op (existing move is safe). + - If `path` itself is a symlink → no-op (existing move is safe). - Else walk ancestors under `root`; for each symlink ancestor `S → T`, replace `S` with a real directory and recreate each child of `T` as a symlink `S/child → T/child` (absolute target). Repeat until the skill entry itself is a symlink or a real local directory under `root`. - - Then `move_entry_without_following_symlink` as today. + - Path containment must tolerate macOS `/var` vs `/private/var` (normalize before + `starts_with`); otherwise materialization is skipped and rename can mutate the project. +6. After disable move, `write_skill_disabled_marker(original_path)`. +7. Before enable move, `remove_skill_disabled_marker(original_path)`. ## API (`apps/api` WS) @@ -46,26 +69,37 @@ Composer / Terminal AI Input ## Frontend (`apps/web`) -- Shared `ComposerSkillsControl` under `features/skills/components/`. -- Mount in Welcome PromptComposer chrome (project id + project path). -- Mount in `TerminalAgentInputShell` footer: - - If `localPath` equals a workspace worktree → workspace mode. - - Else → project mode (use `activeProjectId` / project root). -- Popover: list toggleable skills, Switch per skill, muted tip about new sessions. -- On success: `forceRefreshSkillsList` / invalidate; toast only on error (inline state for success). -- i18n keys in `Welcome` / `terminal.agentInput` / shared `skills.composerDisable` namespaces (`en` + `zh`). +- Entry: `/` slash command id `dynamic-skills`, label **Dynamic Skills** (Welcome + terminal). +- Selecting the command morphs `SlashCommandPopover` into `view: "disable_skills"` (back + Esc + return to the command menu), similar to Agent run-config morph. +- Disable list: Switch per skill; Project/Global/Workspace as inline badge; tip from + `skills.composerDisable.sessionTip` (current-session visibility wording). +- Shared hook: `useComposerDisableSkills` + protocol helpers under `features/skills/`. +- Slash skill-insert list may still show disabled skills as non-selectable with a Disabled badge; + list data shares the skills query cache where practical. +- On success: `forceRefreshSkillsList` / invalidate; toast only on error. +- i18n: `Welcome.components.slashPopover.disableSkill.*`, `terminal.agentInput.disableSkillCommand.*`, + `terminal.agentInput.skillDisable.*`, `skills.composerDisable.*` (`en` + `zh`). ## Risks | Risk | Mitigation | |------|------------| -| `rename` through a parent symlink mutates the project | Materialize ancestors first in workspace mode | +| `rename` through a parent symlink mutates the project | Materialize ancestors first; normalize `/private` path prefix on macOS | | Materialization changes workplace tree shape | Only on toggle; children remain symlinks to project | -| Stale Agent session still sees skill | Explicit UI tip (M6) | +| Permanent `SKILL.md` stub re-discovers skill next session | Use `SKILL_DISABLED.md` only; never leave `SKILL.md` at live path while disabled | +| Agent Runtime still holds baked metadata | Entrypoint removal is best-effort for current session; no Runtime API claim | ## Rollout -1. core-service scan/manage + materialize + tests +1. core-service scan/manage + materialize + marker + tests 2. API WS actions -3. Shared UI control + wire PromptComposer + terminal AI Input +3. `/` Dynamic Skills morph popover on Welcome + terminal AI Input 4. i18n + TEST coverage status + +## Post-Implementation Update · 2026-07-22 + +- Added live-path `SKILL_DISABLED.md` marker write/remove around disable/enable. +- Fixed workspace symlink materialization path-prefix bug (`/var` vs `/private/var`). +- Replaced standalone composer Skills button with `/` **Dynamic Skills** command UX. +- Updated tip/copy away from “new session only”. diff --git a/specs/APP/APP-040_composer-skills-disable/TEST.md b/specs/APP/APP-040_composer-skills-disable/TEST.md index 8bdba086c..0feab1b45 100644 --- a/specs/APP/APP-040_composer-skills-disable/TEST.md +++ b/specs/APP/APP-040_composer-skills-disable/TEST.md @@ -3,66 +3,85 @@ ## Test strategy - **Rust unit tests** for workspace disable with copy and parent-symlink compensation, - and for project disable regression. -- **Bun / component tests** optional for popover tip visibility; not required for M1–M5. -- **Manual / agent-browser**: composer + terminal AI Input toggle smoke. + project disable regression, and live-path `SKILL_DISABLED.md` marker behavior. +- **Bun / component tests** optional for popover tip / slash command wiring; not required for M1–M5. +- **Manual / agent-browser**: Dynamic Skills slash morph + toggle smoke on Welcome and terminal. ## Coverage map | PRD | Scenarios | |-----|-----------| -| M3 | S1 | -| M4 / M5 copy | S2 | -| M5 symlink | S3 | +| M3 / M8 | S1 | +| M4 / M5 copy / M8 | S2 | +| M5 symlink / M8 | S3 | | M6 | S4 | | M1 / M2 | S5 | +| N3 | S6 | ## Execution map | ID | Level | Tool | Target | Status | |----|-------|------|--------|--------| -| S1 | unit | cargo | `core-service` skill tests | pending | -| S2 | unit | cargo | `core-service` skill tests | pending | -| S3 | unit | cargo | `core-service` skill tests | pending | -| S4 | manual | UI | composer popover tip | pending | -| S5 | manual | UI | Welcome + terminal AI Input | pending | +| S1 | unit | cargo | `core-service` skill tests | pass | +| S2 | unit | cargo | `core-service` skill tests | pass | +| S3 | unit | cargo | `core-service` skill tests | pass | +| S4 | manual | UI | Dynamic Skills popover tip | pending | +| S5 | manual | UI | Welcome + terminal `/` Dynamic Skills | pending | +| S6 | manual | UI | `/` skill list Disabled badge | pending | ## Scenarios -### S1 — Project disable matches Skills page storage +### S1 — Project disable matches Skills page storage + marker **Given** a project skill at `/.claude/skills/demo` **When** `SkillManager::set_enabled(..., false)` -**Then** the entry lives under `/.atmos/skills/.disabled/.claude/skills/demo` and scan status is `disabled`. +**Then** the real entry lives under `/.atmos/skills/.disabled/.claude/skills/demo`, +live path has `/.claude/skills/demo/SKILL_DISABLED.md` and **no** `SKILL.md`, +and scan status is `disabled`. +**When** enabled again +**Then** `SKILL.md` is restored and `SKILL_DISABLED.md` is gone. -### S2 — Workspace copy disable is workplace-local +**Signals**: unit assertions on both `.disabled` path and live marker path. + +### S2 — Workspace copy disable is workplace-local + marker **Given** a copied skill under `/.claude/skills/demo` **When** workspace `set_enabled(false)` with workplace scope root -**Then** skill moves to `/.atmos/skills/.disabled/...` and project tree is unchanged. +**Then** skill moves to `/.atmos/skills/.disabled/...`, project tree is unchanged, +and workplace live path keeps `SKILL_DISABLED.md` without `SKILL.md`. ### S3 — Workspace parent-symlink disable does not mutate project **Given** `/.claude` → `/.claude` and skill `demo` under project **When** workspace disable for that skill -**Then** project `demo` still exists; workplace no longer exposes an enabled skill at the original path (entry under workplace `.disabled` as a moved symlink after materialization). +**Then** project `demo/SKILL.md` still exists and project has **no** `SKILL_DISABLED.md`; +workplace no longer exposes `SKILL.md` at the live path; workplace has `SKILL_DISABLED.md`; +disabled storage holds the moved workplace entry (after materialization). -### S4 — New-session tip +### S4 — Dynamic visibility tip -**Given** the composer skills popover is open +**Given** the Dynamic Skills popover is open **When** the user views it -**Then** copy states disable applies to new Agent sessions only. +**Then** copy describes dynamic skill visibility / current-session entrypoint effect +(not “new Agent session only”). -### S5 — Surfaces wired +### S5 — Surfaces wired via `/` Dynamic Skills **Given** Welcome PromptComposer and a workspace terminal AI Input -**When** the skills control is opened -**Then** toggleable skills for that context are listed and toggling succeeds. +**When** the user runs `/` → **Dynamic Skills** +**Then** the popover morphs to the disable list for that context and toggling succeeds. + +### S6 — Disabled skills in slash insert list + +**Given** a disabled manageable skill exists in context +**When** the user opens `/` skill insert list +**Then** the skill may still appear with a Disabled badge and cannot be selected as a skill chip. ## Acceptance criteria -- [ ] S1–S3 automated and green -- [ ] M6 tip present in both surfaces +- [x] S1–S3 automated and green +- [ ] M6 tip present on Dynamic Skills surfaces (current-session wording) +- [ ] `/` Dynamic Skills wired on Welcome + terminal - [ ] Project toggle remains visible/consistent on Skills page after refresh ## Coverage Status @@ -71,10 +90,11 @@ _Updated 2026-07-22_ | ID | Status | Evidence | |----|--------|----------| -| S1 | pass | `cargo +stable test -p core-service --lib service::skill::tests::project_disable_moves_skill_into_disabled_storage` | +| S1 | pass | `cargo +stable test -p core-service --lib skill::tests::project_disable_moves_skill_into_disabled_storage` | | S2 | pass | `…workspace_copy_disable_is_local_to_workplace` | | S3 | pass | `…workspace_parent_symlink_disable_does_not_mutate_project` | -| S4 | pending | Manual — composer popover shows `skills.composerDisable.sessionTip` | -| S5 | pending | Manual — Welcome PromptComposer + terminal AI Input skills control | +| S4 | pending | Manual — `skills.composerDisable.sessionTip` / Dynamic Skills title | +| S5 | pending | Manual — `/` command id `dynamic-skills` on Welcome + terminal | +| S6 | pending | Manual — Disabled badge non-selectable in slash skills list | -Regression: `cargo +stable check -p api` green after WS `skills_scan_root` / `skills_set_enabled.scope_root` additions. +Regression: `cargo +stable check -p api` green after WS `skills_scan_root` / `skills_set_enabled.scope_root` additions; skill unit suite green after `SKILL_DISABLED.md` marker + macOS path-prefix materialize fix. From beb450694f07a13de0281000f2d1c72e5bcfbc0e Mon Sep 17 00:00:00 2001 From: AarynLu Date: Wed, 22 Jul 2026 19:42:53 +0800 Subject: [PATCH 4/5] chore(skills): keep disabled skill trees under .atmos for restore Persist workplace/project disable storage so Atmos can re-enable these skills from .atmos/skills/.disabled later. --- .atmos/skills/.disabled/.agent/skills/adapt | 1 + .atmos/skills/.disabled/.agent/skills/animate | 1 + .../skills/.disabled/.agent/skills/colorize | 1 + .../.disabled/.agents/skills/adapt/SKILL.md | 198 ++++++++++++++++++ .../.agents/skills/agent-browser/SKILL.md | 55 +++++ .../.disabled/.agents/skills/animate/SKILL.md | 190 +++++++++++++++++ .../.agents/skills/colorize/SKILL.md | 158 ++++++++++++++ .atmos/skills/.disabled/.claude/skills/adapt | 1 + .../.disabled/.claude/skills/agent-browser | 1 + .../skills/.disabled/.claude/skills/animate | 1 + .../skills/.disabled/.claude/skills/colorize | 1 + .atmos/skills/.disabled/.factory/skills/adapt | 1 + .../.disabled/.factory/skills/agent-browser | 1 + .../skills/.disabled/.factory/skills/animate | 1 + .../skills/.disabled/.factory/skills/colorize | 1 + .atmos/skills/.disabled/.kiro/skills/adapt | 1 + .../.disabled/.kiro/skills/agent-browser | 1 + .atmos/skills/.disabled/.kiro/skills/animate | 1 + .atmos/skills/.disabled/.kiro/skills/colorize | 1 + 19 files changed, 616 insertions(+) create mode 120000 .atmos/skills/.disabled/.agent/skills/adapt create mode 120000 .atmos/skills/.disabled/.agent/skills/animate create mode 120000 .atmos/skills/.disabled/.agent/skills/colorize create mode 100644 .atmos/skills/.disabled/.agents/skills/adapt/SKILL.md create mode 100644 .atmos/skills/.disabled/.agents/skills/agent-browser/SKILL.md create mode 100644 .atmos/skills/.disabled/.agents/skills/animate/SKILL.md create mode 100644 .atmos/skills/.disabled/.agents/skills/colorize/SKILL.md create mode 120000 .atmos/skills/.disabled/.claude/skills/adapt create mode 120000 .atmos/skills/.disabled/.claude/skills/agent-browser create mode 120000 .atmos/skills/.disabled/.claude/skills/animate create mode 120000 .atmos/skills/.disabled/.claude/skills/colorize create mode 120000 .atmos/skills/.disabled/.factory/skills/adapt create mode 120000 .atmos/skills/.disabled/.factory/skills/agent-browser create mode 120000 .atmos/skills/.disabled/.factory/skills/animate create mode 120000 .atmos/skills/.disabled/.factory/skills/colorize create mode 120000 .atmos/skills/.disabled/.kiro/skills/adapt create mode 120000 .atmos/skills/.disabled/.kiro/skills/agent-browser create mode 120000 .atmos/skills/.disabled/.kiro/skills/animate create mode 120000 .atmos/skills/.disabled/.kiro/skills/colorize diff --git a/.atmos/skills/.disabled/.agent/skills/adapt b/.atmos/skills/.disabled/.agent/skills/adapt new file mode 120000 index 000000000..11781113d --- /dev/null +++ b/.atmos/skills/.disabled/.agent/skills/adapt @@ -0,0 +1 @@ +../../.agents/skills/adapt \ No newline at end of file diff --git a/.atmos/skills/.disabled/.agent/skills/animate b/.atmos/skills/.disabled/.agent/skills/animate new file mode 120000 index 000000000..2721d2108 --- /dev/null +++ b/.atmos/skills/.disabled/.agent/skills/animate @@ -0,0 +1 @@ +../../.agents/skills/animate \ No newline at end of file diff --git a/.atmos/skills/.disabled/.agent/skills/colorize b/.atmos/skills/.disabled/.agent/skills/colorize new file mode 120000 index 000000000..f6f7e18cf --- /dev/null +++ b/.atmos/skills/.disabled/.agent/skills/colorize @@ -0,0 +1 @@ +../../.agents/skills/colorize \ No newline at end of file diff --git a/.atmos/skills/.disabled/.agents/skills/adapt/SKILL.md b/.atmos/skills/.disabled/.agents/skills/adapt/SKILL.md new file mode 100644 index 000000000..7e11509f7 --- /dev/null +++ b/.atmos/skills/.disabled/.agents/skills/adapt/SKILL.md @@ -0,0 +1,198 @@ +--- +name: adapt +description: Adapt designs to work across different screen sizes, devices, contexts, or platforms. Ensures consistent experience across varied environments. +user-invokable: true +args: + - name: target + description: The feature or component to adapt (optional) + required: false + - name: context + description: What to adapt for (mobile, tablet, desktop, print, email, etc.) + required: false +--- + +Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases. + +## Assess Adaptation Challenge + +Understand what needs adaptation and why: + +1. **Identify the source context**: + - What was it designed for originally? (Desktop web? Mobile app?) + - What assumptions were made? (Large screen? Mouse input? Fast connection?) + - What works well in current context? + +2. **Understand target context**: + - **Device**: Mobile, tablet, desktop, TV, watch, print? + - **Input method**: Touch, mouse, keyboard, voice, gamepad? + - **Screen constraints**: Size, resolution, orientation? + - **Connection**: Fast wifi, slow 3G, offline? + - **Usage context**: On-the-go vs desk, quick glance vs focused reading? + - **User expectations**: What do users expect on this platform? + +3. **Identify adaptation challenges**: + - What won't fit? (Content, navigation, features) + - What won't work? (Hover states on touch, tiny touch targets) + - What's inappropriate? (Desktop patterns on mobile, mobile patterns on desktop) + +**CRITICAL**: Adaptation is not just scaling - it's rethinking the experience for the new context. + +## Plan Adaptation Strategy + +Create context-appropriate strategy: + +### Mobile Adaptation (Desktop → Mobile) + +**Layout Strategy**: +- Single column instead of multi-column +- Vertical stacking instead of side-by-side +- Full-width components instead of fixed widths +- Bottom navigation instead of top/side navigation + +**Interaction Strategy**: +- Touch targets 44x44px minimum (not hover-dependent) +- Swipe gestures where appropriate (lists, carousels) +- Bottom sheets instead of dropdowns +- Thumbs-first design (controls within thumb reach) +- Larger tap areas with more spacing + +**Content Strategy**: +- Progressive disclosure (don't show everything at once) +- Prioritize primary content (secondary content in tabs/accordions) +- Shorter text (more concise) +- Larger text (16px minimum) + +**Navigation Strategy**: +- Hamburger menu or bottom navigation +- Reduce navigation complexity +- Sticky headers for context +- Back button in navigation flow + +### Tablet Adaptation (Hybrid Approach) + +**Layout Strategy**: +- Two-column layouts (not single or three-column) +- Side panels for secondary content +- Master-detail views (list + detail) +- Adaptive based on orientation (portrait vs landscape) + +**Interaction Strategy**: +- Support both touch and pointer +- Touch targets 44x44px but allow denser layouts than phone +- Side navigation drawers +- Multi-column forms where appropriate + +### Desktop Adaptation (Mobile → Desktop) + +**Layout Strategy**: +- Multi-column layouts (use horizontal space) +- Side navigation always visible +- Multiple information panels simultaneously +- Fixed widths with max-width constraints (don't stretch to 4K) + +**Interaction Strategy**: +- Hover states for additional information +- Keyboard shortcuts +- Right-click context menus +- Drag and drop where helpful +- Multi-select with Shift/Cmd + +**Content Strategy**: +- Show more information upfront (less progressive disclosure) +- Data tables with many columns +- Richer visualizations +- More detailed descriptions + +### Print Adaptation (Screen → Print) + +**Layout Strategy**: +- Page breaks at logical points +- Remove navigation, footer, interactive elements +- Black and white (or limited color) +- Proper margins for binding + +**Content Strategy**: +- Expand shortened content (show full URLs, hidden sections) +- Add page numbers, headers, footers +- Include metadata (print date, page title) +- Convert charts to print-friendly versions + +### Email Adaptation (Web → Email) + +**Layout Strategy**: +- Narrow width (600px max) +- Single column only +- Inline CSS (no external stylesheets) +- Table-based layouts (for email client compatibility) + +**Interaction Strategy**: +- Large, obvious CTAs (buttons not text links) +- No hover states (not reliable) +- Deep links to web app for complex interactions + +## Implement Adaptations + +Apply changes systematically: + +### Responsive Breakpoints + +Choose appropriate breakpoints: +- Mobile: 320px-767px +- Tablet: 768px-1023px +- Desktop: 1024px+ +- Or content-driven breakpoints (where design breaks) + +### Layout Adaptation Techniques + +- **CSS Grid/Flexbox**: Reflow layouts automatically +- **Container Queries**: Adapt based on container, not viewport +- **`clamp()`**: Fluid sizing between min and max +- **Media queries**: Different styles for different contexts +- **Display properties**: Show/hide elements per context + +### Touch Adaptation + +- Increase touch target sizes (44x44px minimum) +- Add more spacing between interactive elements +- Remove hover-dependent interactions +- Add touch feedback (ripples, highlights) +- Consider thumb zones (easier to reach bottom than top) + +### Content Adaptation + +- Use `display: none` sparingly (still downloads) +- Progressive enhancement (core content first, enhancements on larger screens) +- Lazy loading for off-screen content +- Responsive images (`srcset`, `picture` element) + +### Navigation Adaptation + +- Transform complex nav to hamburger/drawer on mobile +- Bottom nav bar for mobile apps +- Persistent side navigation on desktop +- Breadcrumbs on smaller screens for context + +**IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect. + +**NEVER**: +- Hide core functionality on mobile (if it matters, make it work) +- Assume desktop = powerful device (consider accessibility, older machines) +- Use different information architecture across contexts (confusing) +- Break user expectations for platform (mobile users expect mobile patterns) +- Forget landscape orientation on mobile/tablet +- Use generic breakpoints blindly (use content-driven breakpoints) +- Ignore touch on desktop (many desktop devices have touch) + +## Verify Adaptations + +Test thoroughly across contexts: + +- **Real devices**: Test on actual phones, tablets, desktops +- **Different orientations**: Portrait and landscape +- **Different browsers**: Safari, Chrome, Firefox, Edge +- **Different OS**: iOS, Android, Windows, macOS +- **Different input methods**: Touch, mouse, keyboard +- **Edge cases**: Very small screens (320px), very large screens (4K) +- **Slow connections**: Test on throttled network + +Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. \ No newline at end of file diff --git a/.atmos/skills/.disabled/.agents/skills/agent-browser/SKILL.md b/.atmos/skills/.disabled/.agents/skills/agent-browser/SKILL.md new file mode 100644 index 000000000..cefd75278 --- /dev/null +++ b/.atmos/skills/.disabled/.agents/skills/agent-browser/SKILL.md @@ -0,0 +1,55 @@ +--- +name: agent-browser +description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. +allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +hidden: true +--- + +# agent-browser + +Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with +accessibility-tree snapshots and compact `@eN` element refs. + +Install: `npm i -g agent-browser && agent-browser install` + +## Start here + +This file is a discovery stub, not the usage guide. Before running any +`agent-browser` command, load the actual workflow content from the CLI: + +```bash +agent-browser skills get core # start here — workflows, common patterns, troubleshooting +agent-browser skills get core --full # include full command reference and templates +``` + +The CLI serves skill content that always matches the installed version, +so instructions never go stale. The content in this stub cannot change +between releases, which is why it just points at `skills get core`. + +## Specialized skills + +Load a specialized skill when the task falls outside browser web pages: + +```bash +agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) +agent-browser skills get slack # Slack workspace automation +agent-browser skills get dogfood # Exploratory testing / QA / bug hunts +agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs +agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers +``` + +Run `agent-browser skills list` to see everything available on the +installed version. + +## Why agent-browser + +- Fast native Rust CLI, not a Node.js wrapper +- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) +- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency +- Accessibility-tree snapshots with element refs for reliable interaction +- Sessions, authentication vault, state persistence, video recording +- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers + +## Observability Dashboard + +The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/.atmos/skills/.disabled/.agents/skills/animate/SKILL.md b/.atmos/skills/.disabled/.agents/skills/animate/SKILL.md new file mode 100644 index 000000000..5abd7435f --- /dev/null +++ b/.atmos/skills/.disabled/.agents/skills/animate/SKILL.md @@ -0,0 +1,190 @@ +--- +name: animate +description: Review a feature and enhance it with purposeful animations, micro-interactions, and motion effects that improve usability and delight. +user-invokable: true +args: + - name: target + description: The feature or component to animate (optional) + required: false +--- + +Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight. + +## MANDATORY PREPARATION + +### Context Gathering (Do This First) + +You cannot do a great job without having necessary context, such as target audience (critical), desired use-cases (critical), brand personality/tone (playful vs serious, energetic vs calm), and performance constraints. + +Attempt to gather these from the current thread or codebase. + +1. If you don't find *exact* information and have to infer from existing design and functionality, you MUST STOP and STOP and call the AskUserQuestionTool to clarify. whether you got it right. +2. Otherwise, if you can't fully infer or your level of confidence is medium or lower, you MUST STOP and call the AskUserQuestionTool to clarify. clarifying questions first to complete your context. + +Do NOT proceed until you have answers. Guessing leads to inappropriate or excessive animation. + +### Use frontend-design skill + +Use the frontend-design skill for design principles and anti-patterns. Do NOT proceed until it has executed and you know all DO's and DON'Ts. + +--- + +## Assess Animation Opportunities + +Analyze where motion would improve the experience: + +1. **Identify static areas**: + - **Missing feedback**: Actions without visual acknowledgment (button clicks, form submission, etc.) + - **Jarring transitions**: Instant state changes that feel abrupt (show/hide, page loads, route changes) + - **Unclear relationships**: Spatial or hierarchical relationships that aren't obvious + - **Lack of delight**: Functional but joyless interactions + - **Missed guidance**: Opportunities to direct attention or explain behavior + +2. **Understand the context**: + - What's the personality? (Playful vs serious, energetic vs calm) + - What's the performance budget? (Mobile-first? Complex page?) + - Who's the audience? (Motion-sensitive users? Power users who want speed?) + - What matters most? (One hero animation vs many micro-interactions?) + +If any of these are unclear from the codebase, STOP and call the AskUserQuestionTool to clarify. + +**CRITICAL**: Respect `prefers-reduced-motion`. Always provide non-animated alternatives for users who need them. + +## Plan Animation Strategy + +Create a purposeful animation plan: + +- **Hero moment**: What's the ONE signature animation? (Page load? Hero section? Key interaction?) +- **Feedback layer**: Which interactions need acknowledgment? +- **Transition layer**: Which state changes need smoothing? +- **Delight layer**: Where can we surprise and delight? + +**IMPORTANT**: One well-orchestrated experience beats scattered animations everywhere. Focus on high-impact moments. + +## Implement Animations + +Add motion systematically across these categories: + +### Entrance Animations +- **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations +- **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects) +- **Content reveals**: Scroll-triggered animations using intersection observer +- **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management + +### Micro-interactions +- **Button feedback**: + - Hover: Subtle scale (1.02-1.05), color shift, shadow increase + - Click: Quick scale down then up (0.95 → 1), ripple effect + - Loading: Spinner or pulse state +- **Form interactions**: + - Input focus: Border color transition, slight scale or glow + - Validation: Shake on error, check mark on success, smooth color transitions +- **Toggle switches**: Smooth slide + color transition (200-300ms) +- **Checkboxes/radio**: Check mark animation, ripple effect +- **Like/favorite**: Scale + rotation, particle effects, color transition + +### State Transitions +- **Show/hide**: Fade + slide (not instant), appropriate timing (200-300ms) +- **Expand/collapse**: Height transition with overflow handling, icon rotation +- **Loading states**: Skeleton screen fades, spinner animations, progress bars +- **Success/error**: Color transitions, icon animations, gentle scale pulse +- **Enable/disable**: Opacity transitions, cursor changes + +### Navigation & Flow +- **Page transitions**: Crossfade between routes, shared element transitions +- **Tab switching**: Slide indicator, content fade/slide +- **Carousel/slider**: Smooth transforms, snap points, momentum +- **Scroll effects**: Parallax layers, sticky headers with state changes, scroll progress indicators + +### Feedback & Guidance +- **Hover hints**: Tooltip fade-ins, cursor changes, element highlights +- **Drag & drop**: Lift effect (shadow + scale), drop zone highlights, smooth repositioning +- **Copy/paste**: Brief highlight flash on paste, "copied" confirmation +- **Focus flow**: Highlight path through form or workflow + +### Delight Moments +- **Empty states**: Subtle floating animations on illustrations +- **Completed actions**: Confetti, check mark flourish, success celebrations +- **Easter eggs**: Hidden interactions for discovery +- **Contextual animation**: Weather effects, time-of-day themes, seasonal touches + +## Technical Implementation + +Use appropriate techniques for each animation: + +### Timing & Easing + +**Durations by purpose:** +- **100-150ms**: Instant feedback (button press, toggle) +- **200-300ms**: State changes (hover, menu open) +- **300-500ms**: Layout changes (accordion, modal) +- **500-800ms**: Entrance animations (page load) + +**Easing curves (use these, not CSS defaults):** +```css +/* Recommended - natural deceleration */ +--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */ +--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */ +--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */ + +/* AVOID - feel dated and tacky */ +/* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */ +/* elastic: cubic-bezier(0.68, -0.6, 0.32, 1.6); */ +``` + +**Exit animations are faster than entrances.** Use ~75% of enter duration. + +### CSS Animations +```css +/* Prefer for simple, declarative animations */ +- transitions for state changes +- @keyframes for complex sequences +- transform + opacity only (GPU-accelerated) +``` + +### JavaScript Animation +```javascript +/* Use for complex, interactive animations */ +- Web Animations API for programmatic control +- Framer Motion for React +- GSAP for complex sequences +``` + +### Performance +- **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties +- **will-change**: Add sparingly for known expensive animations +- **Reduce paint**: Minimize repaints, use `contain` where appropriate +- **Monitor FPS**: Ensure 60fps on target devices + +### Accessibility +```css +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +**NEVER**: +- Use bounce or elastic easing curves—they feel dated and draw attention to the animation itself +- Animate layout properties (width, height, top, left)—use transform instead +- Use durations over 500ms for feedback—it feels laggy +- Animate without purpose—every animation needs a reason +- Ignore `prefers-reduced-motion`—this is an accessibility violation +- Animate everything—animation fatigue makes interfaces feel exhausting +- Block interaction during animations unless intentional + +## Verify Quality + +Test animations thoroughly: + +- **Smooth at 60fps**: No jank on target devices +- **Feels natural**: Easing curves feel organic, not robotic +- **Appropriate timing**: Not too fast (jarring) or too slow (laggy) +- **Reduced motion works**: Animations disabled or simplified appropriately +- **Doesn't block**: Users can interact during/after animations +- **Adds value**: Makes interface clearer or more delightful + +Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right. \ No newline at end of file diff --git a/.atmos/skills/.disabled/.agents/skills/colorize/SKILL.md b/.atmos/skills/.disabled/.agents/skills/colorize/SKILL.md new file mode 100644 index 000000000..05b15813a --- /dev/null +++ b/.atmos/skills/.disabled/.agents/skills/colorize/SKILL.md @@ -0,0 +1,158 @@ +--- +name: colorize +description: Add strategic color to features that are too monochromatic or lack visual interest. Makes interfaces more engaging and expressive. +user-invokable: true +args: + - name: target + description: The feature or component to colorize (optional) + required: false +--- + +Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality. + +## MANDATORY PREPARATION + +### Context Gathering (Do This First) + +You cannot do a great job without having necessary context, such as target audience (critical), desired use-cases (critical), brand personality/tone, and especially existing brand colors. + +Attempt to gather these from the current thread or codebase. + +1. If you don't find *exact* information and have to infer from existing design and functionality, you MUST STOP and STOP and call the AskUserQuestionTool to clarify. whether you got it right. +2. Otherwise, if you can't fully infer or your level of confidence is medium or lower, you MUST STOP and call the AskUserQuestionTool to clarify. clarifying questions first to complete your context. + +Do NOT proceed until you have answers. Guessing leads to generic AI slop colors. + +### Use frontend-design skill + +Use the frontend-design skill for design principles and anti-patterns. Do NOT proceed until it has executed and you know all DO's and DON'Ts. + +--- + +## Assess Color Opportunity + +Analyze the current state and identify opportunities: + +1. **Understand current state**: + - **Color absence**: Pure grayscale? Limited neutrals? One timid accent? + - **Missed opportunities**: Where could color add meaning, hierarchy, or delight? + - **Context**: What's appropriate for this domain and audience? + - **Brand**: Are there existing brand colors we should use? + +2. **Identify where color adds value**: + - **Semantic meaning**: Success (green), error (red), warning (yellow/orange), info (blue) + - **Hierarchy**: Drawing attention to important elements + - **Categorization**: Different sections, types, or states + - **Emotional tone**: Warmth, energy, trust, creativity + - **Wayfinding**: Helping users navigate and understand structure + - **Delight**: Moments of visual interest and personality + +If any of these are unclear from the codebase, STOP and call the AskUserQuestionTool to clarify. + +**CRITICAL**: More color ≠ better. Strategic color beats rainbow vomit every time. Every color should have a purpose. + +## Plan Color Strategy + +Create a purposeful color introduction plan: + +- **Color palette**: What colors match the brand/context? (Choose 2-4 colors max beyond neutrals) +- **Dominant color**: Which color owns 60% of colored elements? +- **Accent colors**: Which colors provide contrast and highlights? (30% and 10%) +- **Application strategy**: Where does each color appear and why? + +**IMPORTANT**: Color should enhance hierarchy and meaning, not create chaos. Less is more when it matters more. + +## Introduce Color Strategically + +Add color systematically across these dimensions: + +### Semantic Color +- **State indicators**: + - Success: Green tones (emerald, forest, mint) + - Error: Red/pink tones (rose, crimson, coral) + - Warning: Orange/amber tones + - Info: Blue tones (sky, ocean, indigo) + - Neutral: Gray/slate for inactive states + +- **Status badges**: Colored backgrounds or borders for states (active, pending, completed, etc.) +- **Progress indicators**: Colored bars, rings, or charts showing completion or health + +### Accent Color Application +- **Primary actions**: Color the most important buttons/CTAs +- **Links**: Add color to clickable text (maintain accessibility) +- **Icons**: Colorize key icons for recognition and personality +- **Headers/titles**: Add color to section headers or key labels +- **Hover states**: Introduce color on interaction + +### Background & Surfaces +- **Tinted backgrounds**: Replace pure gray (`#f5f5f5`) with warm neutrals (`oklch(97% 0.01 60)`) or cool tints (`oklch(97% 0.01 250)`) +- **Colored sections**: Use subtle background colors to separate areas +- **Gradient backgrounds**: Add depth with subtle, intentional gradients (not generic purple-blue) +- **Cards & surfaces**: Tint cards or surfaces slightly for warmth + +**Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness *look* equal. Great for generating harmonious scales. + +### Data Visualization +- **Charts & graphs**: Use color to encode categories or values +- **Heatmaps**: Color intensity shows density or importance +- **Comparison**: Color coding for different datasets or timeframes + +### Borders & Accents +- **Accent borders**: Add colored left/top borders to cards or sections +- **Underlines**: Color underlines for emphasis or active states +- **Dividers**: Subtle colored dividers instead of gray lines +- **Focus rings**: Colored focus indicators matching brand + +### Typography Color +- **Colored headings**: Use brand colors for section headings (maintain contrast) +- **Highlight text**: Color for emphasis or categories +- **Labels & tags**: Small colored labels for metadata or categories + +### Decorative Elements +- **Illustrations**: Add colored illustrations or icons +- **Shapes**: Geometric shapes in brand colors as background elements +- **Gradients**: Colorful gradient overlays or mesh backgrounds +- **Blobs/organic shapes**: Soft colored shapes for visual interest + +## Balance & Refinement + +Ensure color addition improves rather than overwhelms: + +### Maintain Hierarchy +- **Dominant color** (60%): Primary brand color or most used accent +- **Secondary color** (30%): Supporting color for variety +- **Accent color** (10%): High contrast for key moments +- **Neutrals** (remaining): Gray/black/white for structure + +### Accessibility +- **Contrast ratios**: Ensure WCAG compliance (4.5:1 for text, 3:1 for UI components) +- **Don't rely on color alone**: Use icons, labels, or patterns alongside color +- **Test for color blindness**: Verify red/green combinations work for all users + +### Cohesion +- **Consistent palette**: Use colors from defined palette, not arbitrary choices +- **Systematic application**: Same color meanings throughout (green always = success) +- **Temperature consistency**: Warm palette stays warm, cool stays cool + +**NEVER**: +- Use every color in the rainbow (choose 2-4 colors beyond neutrals) +- Apply color randomly without semantic meaning +- Put gray text on colored backgrounds—it looks washed out; use a darker shade of the background color or transparency instead +- Use pure gray for neutrals—add subtle color tint (warm or cool) for sophistication +- Use pure black (`#000`) or pure white (`#fff`) for large areas +- Violate WCAG contrast requirements +- Use color as the only indicator (accessibility issue) +- Make everything colorful (defeats the purpose) +- Default to purple-blue gradients (AI slop aesthetic) + +## Verify Color Addition + +Test that colorization improves the experience: + +- **Better hierarchy**: Does color guide attention appropriately? +- **Clearer meaning**: Does color help users understand states/categories? +- **More engaging**: Does the interface feel warmer and more inviting? +- **Still accessible**: Do all color combinations meet WCAG standards? +- **Not overwhelming**: Is color balanced and purposeful? + +Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional. \ No newline at end of file diff --git a/.atmos/skills/.disabled/.claude/skills/adapt b/.atmos/skills/.disabled/.claude/skills/adapt new file mode 120000 index 000000000..11781113d --- /dev/null +++ b/.atmos/skills/.disabled/.claude/skills/adapt @@ -0,0 +1 @@ +../../.agents/skills/adapt \ No newline at end of file diff --git a/.atmos/skills/.disabled/.claude/skills/agent-browser b/.atmos/skills/.disabled/.claude/skills/agent-browser new file mode 120000 index 000000000..e298b7be3 --- /dev/null +++ b/.atmos/skills/.disabled/.claude/skills/agent-browser @@ -0,0 +1 @@ +../../.agents/skills/agent-browser \ No newline at end of file diff --git a/.atmos/skills/.disabled/.claude/skills/animate b/.atmos/skills/.disabled/.claude/skills/animate new file mode 120000 index 000000000..2721d2108 --- /dev/null +++ b/.atmos/skills/.disabled/.claude/skills/animate @@ -0,0 +1 @@ +../../.agents/skills/animate \ No newline at end of file diff --git a/.atmos/skills/.disabled/.claude/skills/colorize b/.atmos/skills/.disabled/.claude/skills/colorize new file mode 120000 index 000000000..f6f7e18cf --- /dev/null +++ b/.atmos/skills/.disabled/.claude/skills/colorize @@ -0,0 +1 @@ +../../.agents/skills/colorize \ No newline at end of file diff --git a/.atmos/skills/.disabled/.factory/skills/adapt b/.atmos/skills/.disabled/.factory/skills/adapt new file mode 120000 index 000000000..11781113d --- /dev/null +++ b/.atmos/skills/.disabled/.factory/skills/adapt @@ -0,0 +1 @@ +../../.agents/skills/adapt \ No newline at end of file diff --git a/.atmos/skills/.disabled/.factory/skills/agent-browser b/.atmos/skills/.disabled/.factory/skills/agent-browser new file mode 120000 index 000000000..e298b7be3 --- /dev/null +++ b/.atmos/skills/.disabled/.factory/skills/agent-browser @@ -0,0 +1 @@ +../../.agents/skills/agent-browser \ No newline at end of file diff --git a/.atmos/skills/.disabled/.factory/skills/animate b/.atmos/skills/.disabled/.factory/skills/animate new file mode 120000 index 000000000..2721d2108 --- /dev/null +++ b/.atmos/skills/.disabled/.factory/skills/animate @@ -0,0 +1 @@ +../../.agents/skills/animate \ No newline at end of file diff --git a/.atmos/skills/.disabled/.factory/skills/colorize b/.atmos/skills/.disabled/.factory/skills/colorize new file mode 120000 index 000000000..f6f7e18cf --- /dev/null +++ b/.atmos/skills/.disabled/.factory/skills/colorize @@ -0,0 +1 @@ +../../.agents/skills/colorize \ No newline at end of file diff --git a/.atmos/skills/.disabled/.kiro/skills/adapt b/.atmos/skills/.disabled/.kiro/skills/adapt new file mode 120000 index 000000000..11781113d --- /dev/null +++ b/.atmos/skills/.disabled/.kiro/skills/adapt @@ -0,0 +1 @@ +../../.agents/skills/adapt \ No newline at end of file diff --git a/.atmos/skills/.disabled/.kiro/skills/agent-browser b/.atmos/skills/.disabled/.kiro/skills/agent-browser new file mode 120000 index 000000000..e298b7be3 --- /dev/null +++ b/.atmos/skills/.disabled/.kiro/skills/agent-browser @@ -0,0 +1 @@ +../../.agents/skills/agent-browser \ No newline at end of file diff --git a/.atmos/skills/.disabled/.kiro/skills/animate b/.atmos/skills/.disabled/.kiro/skills/animate new file mode 120000 index 000000000..2721d2108 --- /dev/null +++ b/.atmos/skills/.disabled/.kiro/skills/animate @@ -0,0 +1 @@ +../../.agents/skills/animate \ No newline at end of file diff --git a/.atmos/skills/.disabled/.kiro/skills/colorize b/.atmos/skills/.disabled/.kiro/skills/colorize new file mode 120000 index 000000000..f6f7e18cf --- /dev/null +++ b/.atmos/skills/.disabled/.kiro/skills/colorize @@ -0,0 +1 @@ +../../.agents/skills/colorize \ No newline at end of file From 896d55210782b7fa2acfec9a75c4008600d4564c Mon Sep 17 00:00:00 2001 From: AarynLu Date: Wed, 22 Jul 2026 19:51:58 +0800 Subject: [PATCH 5/5] fix(skills): address Dynamic Skills review follow-ups Keep canvas workspace side-chat skills context valid, stop collapsing prompt whitespace when stripping the disable chip, and align docs/spec copy. --- apps/docs/content/docs/(app)/how-it-works/meta.json | 2 +- apps/docs/content/docs/(app)/meta.json | 2 +- apps/docs/content/docs/(app)/meta.zh.json | 2 +- .../src/features/canvas/components/CanvasTerminalCard.tsx | 7 ++++++- apps/web/src/features/skills/lib/skill-disable-protocol.ts | 4 +++- specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md | 4 +++- specs/APP/APP-040_composer-skills-disable/TEST.md | 1 + 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/(app)/how-it-works/meta.json b/apps/docs/content/docs/(app)/how-it-works/meta.json index 5fc61dc87..8bfa19823 100644 --- a/apps/docs/content/docs/(app)/how-it-works/meta.json +++ b/apps/docs/content/docs/(app)/how-it-works/meta.json @@ -1,4 +1,4 @@ { - "title": "How It Work", + "title": "How It Works", "pages": ["dynamic-skills"] } diff --git a/apps/docs/content/docs/(app)/meta.json b/apps/docs/content/docs/(app)/meta.json index a54bf8872..b8e14e9ac 100644 --- a/apps/docs/content/docs/(app)/meta.json +++ b/apps/docs/content/docs/(app)/meta.json @@ -10,7 +10,7 @@ "...features", "---Workflows---", "...workflows", - "---How It Work---", + "---How It Works---", "...how-it-works", "---Help & reference---", "...reference" diff --git a/apps/docs/content/docs/(app)/meta.zh.json b/apps/docs/content/docs/(app)/meta.zh.json index f62afc168..f49ec773a 100644 --- a/apps/docs/content/docs/(app)/meta.zh.json +++ b/apps/docs/content/docs/(app)/meta.zh.json @@ -10,7 +10,7 @@ "...features", "---工作流---", "...workflows", - "---How It Work---", + "---工作原理---", "...how-it-works", "---帮助与参考---", "...reference" diff --git a/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx b/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx index 3fbbeb46d..2a9e71e4b 100644 --- a/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx +++ b/apps/web/src/features/canvas/components/CanvasTerminalCard.tsx @@ -161,11 +161,16 @@ function CanvasTerminalCardInner({ shape }: { shape: CanvasTerminalShape }) { startSideChat, } = useTerminalSideChats({ workspaceId: shape.props.workspaceId, + // Canvas terminals store the scope id in `workspaceId` (project id when + // contextScope === "project"). Do not set projectRootPath for workplace + // terminals — equating it to localPath makes side-chat skills treat every + // canvas terminal as project-root and drop workspace Dynamic Skills. projectId: shape.props.contextScope === "project" ? shape.props.workspaceId : null, projectName: shape.props.projectName, workspaceName: shape.props.workspaceName, localPath: shape.props.localPath || null, - projectRootPath: shape.props.localPath || null, + projectRootPath: + shape.props.contextScope === "project" ? shape.props.localPath || null : null, sourcePaneId, sourceSessionId: sessionId, sourceSurfaceKind: "canvas_terminal", diff --git a/apps/web/src/features/skills/lib/skill-disable-protocol.ts b/apps/web/src/features/skills/lib/skill-disable-protocol.ts index cf7c4b49d..10dc2d656 100644 --- a/apps/web/src/features/skills/lib/skill-disable-protocol.ts +++ b/apps/web/src/features/skills/lib/skill-disable-protocol.ts @@ -40,9 +40,11 @@ export function parseSkillDisableProtocolToken(token: string): boolean { export function stripSkillDisableSession(text: string): string { const idx = text.indexOf(SKILL_DISABLE_PROTOCOL); if (idx < 0) return text; + // Only clear spaces that sat immediately after the protocol token (the seam). + // Do not collapse intentional whitespace elsewhere in the prompt. const before = text.slice(0, idx); const after = text.slice(idx + SKILL_DISABLE_PROTOCOL.length).replace(/^[\u00A0 ]+/, ""); - return `${before}${after}`.replace(/[ \u00A0]{2,}/g, " ").trimEnd(); + return `${before}${after}`.trimEnd(); } export function upsertSkillDisableSessionAction( diff --git a/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md index 107ab47bd..f84540aa3 100644 --- a/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md +++ b/specs/APP/APP-040_composer-skills-disable/BRAINSTORM.md @@ -19,7 +19,9 @@ Behavior must match the Skills management page’s move-to-`.atmos/skills/.disab mechanism for Project, and reuse that same move for Workspace with the disabled storage rooted at the **current workplace directory**. Because Atmos does not own Agent Runtime, mid-session effect relies on filesystem entrypoint removal (no `SKILL.md` at the live -path) plus an optional live-path marker file Agents will not treat as a skill. +path) **and** recreating the live skill directory with a mandatory `SKILL_DISABLED.md` +marker (Agents must not treat it as a skill). Alternatives that omit the marker are +rejected; see Option D and Decision. ## Options diff --git a/specs/APP/APP-040_composer-skills-disable/TEST.md b/specs/APP/APP-040_composer-skills-disable/TEST.md index 0feab1b45..a64cbd0b4 100644 --- a/specs/APP/APP-040_composer-skills-disable/TEST.md +++ b/specs/APP/APP-040_composer-skills-disable/TEST.md @@ -83,6 +83,7 @@ disabled storage holds the moved workplace entry (after materialization). - [ ] M6 tip present on Dynamic Skills surfaces (current-session wording) - [ ] `/` Dynamic Skills wired on Welcome + terminal - [ ] Project toggle remains visible/consistent on Skills page after refresh +- [ ] S6: disabled skills in the slash insert list show a Disabled badge and cannot be selected as skill chips ## Coverage Status