From 2033591a51cc3fc4bfa17a2b1300acc68b257434 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 11 Jun 2026 13:26:00 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(skills):=20PostHog=20cloud=20team=20sk?= =?UTF-8?q?ills=20=E2=80=94=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the existing LLMSkill API (products/ai_observability); no posthog-side code changes are required. The llm-analytics-skills flag already gates the endpoints server-side per-request, and llm_skill:read /llm_skill:write are valid self-serve scopes that a *-scoped desktop OAuth token passes. - api-client: listLlmSkills (403 -> null, meaning "feature off"), getLlmSkillByName, resolveLlmSkill, getLlmSkillFile, with handwritten interfaces following the existing api-client convention (this package deliberately has no Zod). - core: TeamSkillsService owns the merged listing decision — feature availability (flag off -> group absent, no errors) and marking team skills that already exist locally by name. Bound via skillsCoreModule in the renderer composition. - UI: "Team" group in SkillsView with read-only detail view rendering the skill body + companion files through the PR 1 components; files fetch lazily on selection. Search filters the team group too. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf --- .../src/renderer/desktop-contributions.ts | 2 + packages/api-client/src/posthog-client.ts | 125 ++++++++++++++++ packages/core/src/skills/identifiers.ts | 3 + packages/core/src/skills/skills.module.ts | 7 + .../core/src/skills/teamSkillsService.test.ts | 82 +++++++++++ packages/core/src/skills/teamSkillsService.ts | 62 ++++++++ .../ui/src/features/skills/SkillsView.tsx | 16 +- .../features/skills/TeamSkillDetailPanel.tsx | 137 ++++++++++++++++++ .../src/features/skills/TeamSkillsSection.tsx | 58 ++++++++ .../ui/src/features/skills/TeamSkillsTab.tsx | 104 +++++++++++++ .../ui/src/features/skills/useTeamSkills.ts | 42 ++++++ 11 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/skills/identifiers.ts create mode 100644 packages/core/src/skills/skills.module.ts create mode 100644 packages/core/src/skills/teamSkillsService.test.ts create mode 100644 packages/core/src/skills/teamSkillsService.ts create mode 100644 packages/ui/src/features/skills/TeamSkillDetailPanel.tsx create mode 100644 packages/ui/src/features/skills/TeamSkillsSection.tsx create mode 100644 packages/ui/src/features/skills/TeamSkillsTab.tsx create mode 100644 packages/ui/src/features/skills/useTeamSkills.ts diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index efa7a5f59a..4341aa0deb 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -3,6 +3,7 @@ import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module"; import { onboardingModule } from "@posthog/core/onboarding/onboarding.module"; import { setupCoreModule } from "@posthog/core/setup/setup.module"; +import { skillsCoreModule } from "@posthog/core/skills/skills.module"; import { CONTRIBUTION } from "@posthog/di/contribution"; import { agentUiModule } from "@posthog/ui/features/agent/agent.module"; import { authUiModule } from "@posthog/ui/features/auth/auth.module"; @@ -38,6 +39,7 @@ export function registerDesktopContributions(): void { provisioningUiModule, setupCoreModule, setupUiModule, + skillsCoreModule, workspaceUiModule, ]) { container.load(module); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e5762e38af..ce3dc38e88 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -143,6 +143,58 @@ export interface UserGitHubIntegration { created_at?: string; } +export interface LlmSkillCreatedBy { + id?: number; + email?: string | null; + first_name?: string | null; + last_name?: string | null; +} + +export interface LlmSkillFileManifest { + path: string; + content_type: string; +} + +export interface LlmSkillFile { + path: string; + content: string; + content_type: string; +} + +export interface LlmSkillListItem { + id: string; + name: string; + description: string; + allowed_tools: unknown[]; + metadata: Record; + version: number; + is_latest: boolean; + latest_version?: number | null; + version_count?: number | null; + created_by: LlmSkillCreatedBy | null; + created_at: string; + updated_at: string; +} + +export interface LlmSkill extends LlmSkillListItem { + /** The SKILL.md markdown content. */ + body: string; + /** Companion file manifest (paths only; fetch contents separately). */ + files: LlmSkillFileManifest[]; +} + +export interface LlmSkillResolveResponse { + skill: LlmSkill; + versions: Array<{ + id: string; + version: number; + created_by: LlmSkillCreatedBy | null; + created_at: string; + is_latest: boolean; + }>; + has_more: boolean; +} + export interface SignalSourceConfig { id: string; source_product: @@ -3627,4 +3679,77 @@ export class PostHogAPIClient { } return (await response.json()) as SpendAnalysisResponse; } + + /** + * Lists the team's LLM skills (latest versions, no bodies). + * Returns null when the feature is unavailable for this org (the + * llm-analytics-skills flag gates the endpoint server-side with a 403). + */ + async listLlmSkills(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/environments/${teamId}/llm_skills/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (response.status === 403) return null; + if (!response.ok) { + throw new Error(`Failed to fetch team skills: ${response.statusText}`); + } + const data = (await response.json()) as { results?: LlmSkillListItem[] }; + return data.results ?? []; + } + + /** Fetches the latest version of a team skill, including body and file manifest. */ + async getLlmSkillByName(name: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch team skill: ${response.statusText}`); + } + return (await response.json()) as LlmSkill; + } + + /** Resolves a team skill plus its version history. */ + async resolveLlmSkill(name: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/environments/${teamId}/llm_skills/resolve/name/${encodeURIComponent(name)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to resolve team skill: ${response.statusText}`); + } + return (await response.json()) as LlmSkillResolveResponse; + } + + /** Fetches one companion file of a team skill. */ + async getLlmSkillFile(name: string, filePath: string): Promise { + const teamId = await this.getTeamId(); + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}/files/${encodedPath}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch team skill file: ${response.statusText}`, + ); + } + return (await response.json()) as LlmSkillFile; + } } diff --git a/packages/core/src/skills/identifiers.ts b/packages/core/src/skills/identifiers.ts new file mode 100644 index 0000000000..9c8ee72c7a --- /dev/null +++ b/packages/core/src/skills/identifiers.ts @@ -0,0 +1,3 @@ +export const TEAM_SKILLS_SERVICE = Symbol.for( + "posthog.core.skills.teamSkillsService", +); diff --git a/packages/core/src/skills/skills.module.ts b/packages/core/src/skills/skills.module.ts new file mode 100644 index 0000000000..0c3cce3b52 --- /dev/null +++ b/packages/core/src/skills/skills.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { TEAM_SKILLS_SERVICE } from "./identifiers"; +import { TeamSkillsService } from "./teamSkillsService"; + +export const skillsCoreModule = new ContainerModule(({ bind }) => { + bind(TEAM_SKILLS_SERVICE).to(TeamSkillsService).inSingletonScope(); +}); diff --git a/packages/core/src/skills/teamSkillsService.test.ts b/packages/core/src/skills/teamSkillsService.test.ts new file mode 100644 index 0000000000..c556fae35b --- /dev/null +++ b/packages/core/src/skills/teamSkillsService.test.ts @@ -0,0 +1,82 @@ +import type { + LlmSkillListItem, + PostHogAPIClient, +} from "@posthog/api-client/posthog-client"; +import { describe, expect, it, vi } from "vitest"; +import { TeamSkillsService } from "./teamSkillsService"; + +function makeItem(overrides: Partial): LlmSkillListItem { + return { + id: "skill-1", + name: "pr-shepherd", + description: "Shepherds PRs", + allowed_tools: [], + metadata: {}, + version: 2, + is_latest: true, + latest_version: 2, + created_by: { email: "dev@posthog.com" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-02-01T00:00:00Z", + ...overrides, + }; +} + +function makeClient(result: LlmSkillListItem[] | null): PostHogAPIClient { + return { + listLlmSkills: vi.fn().mockResolvedValue(result), + } as unknown as PostHogAPIClient; +} + +describe("TeamSkillsService.listTeamSkills", () => { + it("reports the feature as unavailable when the API returns null", async () => { + const listing = await new TeamSkillsService().listTeamSkills( + makeClient(null), + [], + ); + + expect(listing).toEqual({ available: false, skills: [] }); + }); + + it("maps team skills and marks ones that exist locally", async () => { + const client = makeClient([ + makeItem({}), + makeItem({ id: "skill-2", name: "release-notes", created_by: null }), + ]); + + const listing = await new TeamSkillsService().listTeamSkills(client, [ + "release-notes", + "unrelated-local", + ]); + + expect(listing.available).toBe(true); + expect(listing.skills).toEqual([ + { + id: "skill-1", + name: "pr-shepherd", + description: "Shepherds PRs", + version: 2, + updatedAt: "2026-02-01T00:00:00Z", + createdByEmail: "dev@posthog.com", + installedLocally: false, + }, + expect.objectContaining({ + name: "release-notes", + createdByEmail: null, + installedLocally: true, + }), + ]); + }); + + it("drops non-latest versions", async () => { + const client = makeClient([ + makeItem({ is_latest: false, version: 1 }), + makeItem({ id: "skill-1b", version: 2 }), + ]); + + const listing = await new TeamSkillsService().listTeamSkills(client, []); + + expect(listing.skills).toHaveLength(1); + expect(listing.skills[0]?.id).toBe("skill-1b"); + }); +}); diff --git a/packages/core/src/skills/teamSkillsService.ts b/packages/core/src/skills/teamSkillsService.ts new file mode 100644 index 0000000000..9f72335bc1 --- /dev/null +++ b/packages/core/src/skills/teamSkillsService.ts @@ -0,0 +1,62 @@ +import type { + LlmSkillListItem, + PostHogAPIClient, +} from "@posthog/api-client/posthog-client"; +import { injectable } from "inversify"; + +export interface TeamSkillInfo { + id: string; + name: string; + description: string; + version: number; + updatedAt: string; + createdByEmail: string | null; + /** A local skill with the same name already exists on this machine. */ + installedLocally: boolean; +} + +export interface TeamSkillsListing { + /** False when the org does not have the team-skills feature enabled. */ + available: boolean; + skills: TeamSkillInfo[]; +} + +@injectable() +export class TeamSkillsService { + /** + * Lists team skills merged with the local listing: the availability + * decision (flag off → absent group, no errors) and the "already + * installed locally" marking both live here, so the UI keeps one hook. + */ + async listTeamSkills( + client: PostHogAPIClient, + localSkillNames: string[], + ): Promise { + const items = await client.listLlmSkills(); + if (items === null) { + return { available: false, skills: [] }; + } + const localNames = new Set(localSkillNames); + return { + available: true, + skills: items + .filter((item) => item.is_latest) + .map((item) => toTeamSkillInfo(item, localNames)), + }; + } +} + +function toTeamSkillInfo( + item: LlmSkillListItem, + localNames: Set, +): TeamSkillInfo { + return { + id: item.id, + name: item.name, + description: item.description, + version: item.latest_version ?? item.version, + updatedAt: item.updated_at, + createdByEmail: item.created_by?.email ?? null, + installedLocally: localNames.has(item.name), + }; +} diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index be7c438dea..bc68d44a31 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -22,12 +22,16 @@ import { useSkillsSelectionActions, } from "./skillsSelectionStore"; import { useSkillsSidebarStore } from "./skillsSidebarStore"; +import { TeamSkillsTab } from "./TeamSkillsTab"; import { useSkills } from "./useSkills"; import { useSkillsWatcher } from "./useSkillsWatcher"; +import { useTeamSkills } from "./useTeamSkills"; const SOURCE_ORDER: SkillSource[] = ["user", "marketplace", "repo", "bundled"]; -type SkillsTab = "installed" | "marketplace"; +// Installed = on disk, usable by agents right now. Team and Marketplace are +// remote catalogs; installing materializes a skill into Installed. +type SkillsTab = "installed" | "team" | "marketplace"; export function SkillsView() { const { data: skills = [], isLoading } = useSkills(); @@ -39,6 +43,9 @@ export function SkillsView() { const [searchQuery, setSearchQuery] = useState(""); const [newSkillOpen, setNewSkillOpen] = useState(false); + const { data: teamListing } = useTeamSkills(skills); + const teamAvailable = teamListing?.available ?? false; + const { width: sidebarWidth, setWidth: setSidebarWidth, @@ -127,6 +134,11 @@ export function SkillsView() { Installed + {teamAvailable && ( + + Team + + )} Marketplace @@ -136,6 +148,8 @@ export function SkillsView() { {tab === "marketplace" ? ( + ) : tab === "team" && teamAvailable ? ( + ) : ( diff --git a/packages/ui/src/features/skills/TeamSkillDetailPanel.tsx b/packages/ui/src/features/skills/TeamSkillDetailPanel.tsx new file mode 100644 index 0000000000..6649a765f2 --- /dev/null +++ b/packages/ui/src/features/skills/TeamSkillDetailPanel.tsx @@ -0,0 +1,137 @@ +import { UsersThree, X } from "@phosphor-icons/react"; +import type { TeamSkillInfo } from "@posthog/core/skills/teamSkillsService"; +import { CodeMirrorEditor } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { Badge, Box, Flex, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; +import { useState } from "react"; +import { SkillFileTree } from "./SkillFileTree"; +import { stripFrontmatter } from "./stripFrontmatter"; +import { useTeamSkillDetail, useTeamSkillFile } from "./useTeamSkills"; + +interface TeamSkillDetailPanelProps { + skill: TeamSkillInfo; + onClose: () => void; +} + +/** Read-only view of a PostHog cloud team skill (body + companion files). */ +export function TeamSkillDetailPanel({ + skill, + onClose, +}: TeamSkillDetailPanelProps) { + const [selectedFile, setSelectedFile] = useState("SKILL.md"); + const { data: detail, isLoading } = useTeamSkillDetail(skill.name); + const isSkillMd = selectedFile === "SKILL.md"; + const { data: file, isLoading: isFileLoading } = useTeamSkillFile( + skill.name, + isSkillMd ? null : selectedFile, + ); + + const treeFiles = [ + { path: "SKILL.md", size: detail?.body.length ?? 0 }, + ...(detail?.files ?? []).map((f) => ({ path: f.path, size: 0 })), + ]; + + return ( + <> + + + + {skill.name} + + + + + + + + + + Team + + + v{skill.version} + + {skill.createdByEmail && ( + + {skill.createdByEmail} + + )} + {skill.installedLocally && ( + + Installed + + )} + + + + {treeFiles.length > 1 && ( + + + + )} + + + {isSkillMd ? ( + + + {skill.description && ( + + {skill.description} + + )} + {isLoading ? ( + Loading... + ) : detail?.body ? ( + + + + ) : ( + + No content in SKILL.md + + )} + + + ) : isFileLoading ? ( + + Loading... + + ) : file ? ( + + ) : ( + + + Unable to display this file + + + )} + + + ); +} diff --git a/packages/ui/src/features/skills/TeamSkillsSection.tsx b/packages/ui/src/features/skills/TeamSkillsSection.tsx new file mode 100644 index 0000000000..50fe789c1d --- /dev/null +++ b/packages/ui/src/features/skills/TeamSkillsSection.tsx @@ -0,0 +1,58 @@ +import { UsersThree } from "@phosphor-icons/react"; +import type { TeamSkillInfo } from "@posthog/core/skills/teamSkillsService"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; + +interface TeamSkillsSectionProps { + skills: TeamSkillInfo[]; + selectedName: string | null; + onSelect: (skill: TeamSkillInfo) => void; +} + +/** Skill cards shared via PostHog cloud, read-only here. */ +export function TeamSkillsSection({ + skills, + selectedName, + onSelect, +}: TeamSkillsSectionProps) { + return ( + + {skills.map((skill) => ( + onSelect(skill)} + > + + + + + + {skill.name} + + {skill.description && ( + + {skill.description} + + )} + + {skill.installedLocally && ( + + Installed + + )} + + v{skill.version} + + + ))} + + ); +} diff --git a/packages/ui/src/features/skills/TeamSkillsTab.tsx b/packages/ui/src/features/skills/TeamSkillsTab.tsx new file mode 100644 index 0000000000..ef9729542d --- /dev/null +++ b/packages/ui/src/features/skills/TeamSkillsTab.tsx @@ -0,0 +1,104 @@ +import { MagnifyingGlass, UsersThree } from "@phosphor-icons/react"; +import type { TeamSkillInfo } from "@posthog/core/skills/teamSkillsService"; +import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; +import { Box, Flex, ScrollArea, Text, TextField } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useSkillsSidebarStore } from "./skillsSidebarStore"; +import { TeamSkillDetailPanel } from "./TeamSkillDetailPanel"; +import { TeamSkillsSection } from "./TeamSkillsSection"; + +interface TeamSkillsTabProps { + /** Latest team skills, already merged with the local listing. */ + skills: TeamSkillInfo[]; +} + +/** Skills your team published to PostHog cloud; install to use locally. */ +export function TeamSkillsTab({ skills }: TeamSkillsTabProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selected, setSelected] = useState(null); + + const { + width: sidebarWidth, + setWidth: setSidebarWidth, + isResizing, + setIsResizing, + } = useSkillsSidebarStore(); + + const filtered = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return skills; + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(query) || + skill.description.toLowerCase().includes(query), + ); + }, [skills, searchQuery]); + + return ( + + + + + + setSearchQuery(e.target.value)} + className="text-[13px]" + > + + + + + + + {filtered.length === 0 ? ( + + + + + + {skills.length === 0 + ? "No team skills yet. Publish one of your skills to share it with your team." + : "No team skills match your search"} + + + ) : ( + + setSelected((prev) => (prev?.id === skill.id ? null : skill)) + } + /> + )} + + + + + + {selected && ( + setSelected(null)} + /> + )} + + + ); +} diff --git a/packages/ui/src/features/skills/useTeamSkills.ts b/packages/ui/src/features/skills/useTeamSkills.ts new file mode 100644 index 0000000000..44a2f5afe6 --- /dev/null +++ b/packages/ui/src/features/skills/useTeamSkills.ts @@ -0,0 +1,42 @@ +import { TEAM_SKILLS_SERVICE } from "@posthog/core/skills/identifiers"; +import type { TeamSkillsService } from "@posthog/core/skills/teamSkillsService"; +import { useService } from "@posthog/di/react"; +import type { SkillInfo } from "@posthog/shared"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useMemo } from "react"; + +export const teamSkillsKeys = { + list: (localNames: string[]) => ["team-skills", "list", localNames] as const, + detail: (name: string) => ["team-skills", "detail", name] as const, + file: (name: string, path: string) => + ["team-skills", "file", name, path] as const, +}; + +export function useTeamSkills(localSkills: SkillInfo[]) { + const service = useService(TEAM_SKILLS_SERVICE); + const localNames = useMemo( + () => [...new Set(localSkills.map((s) => s.name))].sort(), + [localSkills], + ); + return useAuthenticatedQuery( + teamSkillsKeys.list(localNames), + (client) => service.listTeamSkills(client, localNames), + { staleTime: 60_000, retry: false }, + ); +} + +export function useTeamSkillDetail(name: string | null) { + return useAuthenticatedQuery( + teamSkillsKeys.detail(name ?? ""), + (client) => client.getLlmSkillByName(name ?? ""), + { enabled: name !== null, staleTime: 60_000, retry: false }, + ); +} + +export function useTeamSkillFile(name: string, filePath: string | null) { + return useAuthenticatedQuery( + teamSkillsKeys.file(name, filePath ?? ""), + (client) => client.getLlmSkillFile(name, filePath ?? ""), + { enabled: filePath !== null, staleTime: 60_000, retry: false }, + ); +} From f478aff9b75a9724e8203b3029c001f39e22fcf6 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 12 Jun 2026 08:22:34 +0100 Subject: [PATCH 2/2] fix(skills): fall back to Installed when team access is revoked, type the client mock Review feedback on #2609: the active tab is derived so a mid-session 403 cannot leave the view on a Team tab whose trigger no longer renders; the test client mock is validated against the real method signature via satisfies + a typed vi.fn. Generated-By: PostHog Code Task-Id: f4e84f1a-19c9-490c-9b98-47787a7dddcf --- packages/core/src/skills/teamSkillsService.test.ts | 6 ++++-- packages/ui/src/features/skills/SkillsView.tsx | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/skills/teamSkillsService.test.ts b/packages/core/src/skills/teamSkillsService.test.ts index c556fae35b..910b669015 100644 --- a/packages/core/src/skills/teamSkillsService.test.ts +++ b/packages/core/src/skills/teamSkillsService.test.ts @@ -24,8 +24,10 @@ function makeItem(overrides: Partial): LlmSkillListItem { function makeClient(result: LlmSkillListItem[] | null): PostHogAPIClient { return { - listLlmSkills: vi.fn().mockResolvedValue(result), - } as unknown as PostHogAPIClient; + listLlmSkills: vi + .fn() + .mockResolvedValue(result), + } satisfies Partial as unknown as PostHogAPIClient; } describe("TeamSkillsService.listTeamSkills", () => { diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index bc68d44a31..1e5c41a388 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -45,6 +45,9 @@ export function SkillsView() { const { data: teamListing } = useTeamSkills(skills); const teamAvailable = teamListing?.available ?? false; + // Team access revoked mid-session: fall back to Installed. + const activeTab: SkillsTab = + tab === "team" && !teamAvailable ? "installed" : tab; const { width: sidebarWidth, @@ -127,7 +130,7 @@ export function SkillsView() { setTab(value as SkillsTab)} > @@ -146,9 +149,9 @@ export function SkillsView() { - {tab === "marketplace" ? ( + {activeTab === "marketplace" ? ( - ) : tab === "team" && teamAvailable ? ( + ) : activeTab === "team" ? ( ) : (