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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
398 changes: 398 additions & 0 deletions docs/plans/skills-tab.md

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion packages/host-router/src/routers/skills.router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
import { SKILLS_SERVICE } from "@posthog/workspace-server/services/skills/identifiers";
import { listSkillsOutput } from "@posthog/workspace-server/services/skills/schemas";
import {
listSkillsOutput,
readSkillFileInput,
readSkillFileOutput,
skillContentsInput,
skillContentsOutput,
} from "@posthog/workspace-server/services/skills/schemas";
import type { SkillsService } from "@posthog/workspace-server/services/skills/skills";

export const skillsRouter = router({
Expand All @@ -9,4 +15,20 @@ export const skillsRouter = router({
.query(({ ctx }) =>
ctx.container.get<SkillsService>(SKILLS_SERVICE).listSkills(),
),
contents: publicProcedure
.input(skillContentsInput)
.output(skillContentsOutput)
.query(({ ctx, input }) =>
ctx.container
.get<SkillsService>(SKILLS_SERVICE)
.getSkillContents(input.skillPath),
),
readFile: publicProcedure
.input(readSkillFileInput)
.output(readSkillFileOutput)
.query(({ ctx, input }) =>
ctx.container
.get<SkillsService>(SKILLS_SERVICE)
.readSkillFile(input.skillPath, input.filePath),
),
});
2 changes: 1 addition & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export type {
SignalReportOrderingField,
SignalReportStatus,
} from "./signal-types";
export type { SkillInfo, SkillSource } from "./skills";
export type { SkillFileEntry, SkillInfo, SkillSource } from "./skills";
export type {
ArtifactType,
PostHogAPIConfig,
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ export interface SkillInfo {
source: SkillSource;
path: string;
repoName?: string;
/** Whether the skill lives in a directory we own on the user's behalf. */
editable: boolean;
}

export interface SkillFileEntry {
/** Path relative to the skill directory, using "/" separators. */
path: string;
size: number;
}
99 changes: 71 additions & 28 deletions packages/ui/src/features/skills/SkillDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Folder, X } from "@phosphor-icons/react";
import { Folder, LockSimple, X } from "@phosphor-icons/react";
import type { SkillInfo } from "@posthog/shared";
import { CodeMirrorEditor } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor";
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
import { ExternalAppsOpener } from "@posthog/ui/features/task-detail/components/ExternalAppsOpener";
import { Badge, Box, Flex, ScrollArea, Text } from "@radix-ui/themes";
import { useAbsoluteFileContent } from "../code-editor/hooks/useFileContent";
import { useState } from "react";
import { SOURCE_CONFIG } from "./SkillCard";
import { SkillFileTree } from "./SkillFileTree";
import { useSkillContents, useSkillFile } from "./useSkillContents";

function stripFrontmatter(content: string): string {
const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n/);
Expand All @@ -19,13 +22,16 @@ interface SkillDetailPanelProps {
export function SkillDetailPanel({ skill, onClose }: SkillDetailPanelProps) {
const config = SOURCE_CONFIG[skill.source];

const skillMdPath = `${skill.path}/SKILL.md`;
const { data: fileContent, isLoading } = useAbsoluteFileContent(
skillMdPath,
true,
const [selectedFile, setSelectedFile] = useState("SKILL.md");
const { data: contents } = useSkillContents(skill.path);
const { data: fileContent, isLoading } = useSkillFile(
skill.path,
selectedFile,
);

const body = fileContent ? stripFrontmatter(fileContent) : null;
const files = contents?.files ?? [];
const isSkillMd = selectedFile === "SKILL.md";
const body = isSkillMd && fileContent ? stripFrontmatter(fileContent) : null;

return (
<>
Expand Down Expand Up @@ -59,37 +65,74 @@ export function SkillDetailPanel({ skill, onClose }: SkillDetailPanelProps) {
{skill.repoName}
</Badge>
)}
{!skill.editable && (
<Badge size="1" variant="soft" color="gray">
<LockSimple size={10} className="text-gray-9" />
Read-only
</Badge>
)}
{skill.source !== "bundled" && (
<ExternalAppsOpener targetPath={skill.path} />
)}
</Flex>
</Flex>

<ScrollArea
type="auto"
scrollbars="vertical"
className="scroll-area-constrain-width h-full"
>
<Flex direction="column" gap="3" p="3">
{skill.description && (
<Text className="text-[12px] text-gray-10">
{skill.description}
</Text>
)}
{files.length > 1 && (
<Box className="max-h-[40%] shrink-0 overflow-y-auto border-b border-b-(--gray-5) py-1">
<SkillFileTree
files={files}
selectedPath={selectedFile}
onSelect={setSelectedFile}
/>
</Box>
)}

<Box className="min-h-0 flex-1">
{isSkillMd ? (
<ScrollArea
type="auto"
scrollbars="vertical"
className="scroll-area-constrain-width h-full"
>
<Flex direction="column" gap="3" p="3">
{skill.description && (
<Text className="text-[12px] text-gray-10">
{skill.description}
</Text>
)}

{isLoading ? (
{isLoading ? (
<Text className="text-[12px] text-gray-9">Loading...</Text>
) : body ? (
<Box className="rounded border border-gray-5 bg-gray-1 px-4 py-3 text-[13px]">
<MarkdownRenderer content={body} />
</Box>
) : (
<Text className="text-[12px] text-gray-9">
No content in SKILL.md
</Text>
)}
</Flex>
</ScrollArea>
) : isLoading ? (
<Box p="3">
<Text className="text-[12px] text-gray-9">Loading...</Text>
) : body ? (
<Box className="rounded border border-gray-5 bg-gray-1 px-4 py-3 text-[13px]">
<MarkdownRenderer content={body} />
</Box>
) : (
</Box>
) : fileContent != null ? (
<CodeMirrorEditor
content={fileContent}
filePath={`${skill.path}/${selectedFile}`}
relativePath={selectedFile}
readOnly
/>
) : (
<Box p="3">
<Text className="text-[12px] text-gray-9">
No content in SKILL.md
Unable to display this file
</Text>
)}
</Flex>
</ScrollArea>
</Box>
)}
</Box>
</>
);
}
91 changes: 91 additions & 0 deletions packages/ui/src/features/skills/SkillFileTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { SkillFileEntry } from "@posthog/shared";
import {
TreeDirectoryRow,
TreeFileRow,
} from "@posthog/ui/primitives/TreeDirectoryRow";
import { Flex } from "@radix-ui/themes";
import { useMemo, useState } from "react";

interface TreeDir {
name: string;
path: string;
dirs: TreeDir[];
files: { name: string; path: string }[];
}

function buildTree(files: SkillFileEntry[]): TreeDir {
const root: TreeDir = { name: "", path: "", dirs: [], files: [] };
for (const file of files) {
const parts = file.path.split("/");
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const dirPath = parts.slice(0, i + 1).join("/");
let child = node.dirs.find((d) => d.path === dirPath);
if (!child) {
child = { name: parts[i] ?? "", path: dirPath, dirs: [], files: [] };
node.dirs.push(child);
}
node = child;
}
node.files.push({ name: parts[parts.length - 1] ?? "", path: file.path });
}
return root;
}

interface SkillFileTreeProps {
files: SkillFileEntry[];
selectedPath: string | null;
onSelect: (path: string) => void;
}

export function SkillFileTree({
files,
selectedPath,
onSelect,
}: SkillFileTreeProps) {
const tree = useMemo(() => buildTree(files), [files]);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());

const toggleDir = (dirPath: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
}
return next;
});
};

const renderDir = (dir: TreeDir, depth: number): React.ReactNode => (
<Flex direction="column" key={dir.path || "__root"}>
{dir.dirs.map((child) => {
const isExpanded = !collapsed.has(child.path);
return (
<Flex direction="column" key={child.path}>
<TreeDirectoryRow
name={child.name}
depth={depth}
isExpanded={isExpanded}
onToggle={() => toggleDir(child.path)}
/>
{isExpanded && renderDir(child, depth + 1)}
</Flex>
);
})}
{dir.files.map((file) => (
<TreeFileRow
key={file.path}
fileName={file.name}
depth={depth}
isActive={selectedPath === file.path}
title={file.path}
onClick={() => onSelect(file.path)}
/>
))}
</Flex>
);

return renderDir(tree, 0);
}
1 change: 1 addition & 0 deletions packages/ui/src/features/skills/SkillsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function SkillsView() {
>
{selectedSkill && (
<SkillDetailPanel
key={selectedSkill.path}
skill={selectedSkill}
onClose={handleCloseSidebar}
/>
Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/features/skills/useSkillContents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useHostTRPC } from "@posthog/host-router/react";
import { useQuery } from "@tanstack/react-query";

export function useSkillContents(skillPath: string) {
const trpc = useHostTRPC();
return useQuery(
trpc.skills.contents.queryOptions({ skillPath }, { staleTime: 30_000 }),
);
}

export function useSkillFile(skillPath: string, filePath: string | null) {
const trpc = useHostTRPC();
return useQuery(
trpc.skills.readFile.queryOptions(
{ skillPath, filePath: filePath ?? "" },
{ enabled: filePath !== null, staleTime: 30_000 },
),
);
}
24 changes: 24 additions & 0 deletions packages/workspace-server/src/services/skills/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,33 @@ export const skillInfo = z.object({
source: skillSource,
path: z.string(),
repoName: z.string().optional(),
editable: z.boolean(),
});

export const listSkillsOutput = z.array(skillInfo);

export const skillFileEntry = z.object({
// Path relative to the skill directory, using "/" separators.
path: z.string(),
size: z.number(),
});

export const skillContentsInput = z.object({
skillPath: z.string(),
});

export const skillContentsOutput = z.object({
files: z.array(skillFileEntry),
});

export const readSkillFileInput = z.object({
skillPath: z.string(),
filePath: z.string(),
});

export const readSkillFileOutput = z.string().nullable();

export type SkillInfo = z.infer<typeof skillInfo>;
export type SkillSource = z.infer<typeof skillSource>;
export type SkillFileEntry = z.infer<typeof skillFileEntry>;
export type SkillContents = z.infer<typeof skillContentsOutput>;
Loading
Loading