Skip to content
Closed
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
31 changes: 26 additions & 5 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ import {
scoreQueryMatch,
} from "@t3tools/shared/searchRanking";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { mergeProviderSkills } from "@t3tools/client-runtime/providerSkills";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import {
type ExistingThreadSettingsRouteSession,
Expand Down Expand Up @@ -354,6 +357,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) ?? null
);
}, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
const projectSkillsQuery = useEnvironmentQuery(
selectedProviderStatus?.driver === "codex" && props.projectCwd
? projectEnvironment.listSkills({
environmentId: props.environmentId,
input: { cwd: props.projectCwd },
})
Comment thread
Githubguy132010 marked this conversation as resolved.
: null,
);
const selectedProviderSkills = useMemo(
() => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []),
[projectSkillsQuery.data, selectedProviderStatus?.skills],
);

// ── Trigger detection ────────────────────────────────────
const [composerSelection, setComposerSelection] = useState(() => ({
Expand Down Expand Up @@ -434,7 +449,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

if (composerTrigger.kind === "skill") {
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
const enabledSkills = selectedProviderSkills.filter((s) => s.enabled);
const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
trimLeadingPattern: /^\$+/,
});
Expand Down Expand Up @@ -531,7 +546,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

return [];
}, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
}, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderStatus]);

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -722,12 +737,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
layout={COMPOSER_LAYOUT_TRANSITION}
style={{ maxWidth: props.contentMaxWidth }}
>
{composerTrigger && composerMenuItems.length > 0 ? (
{composerTrigger &&
(composerMenuItems.length > 0 ||
(composerTrigger.kind === "skill" && projectSkillsQuery.isPending)) ? (
<View className="absolute inset-x-0 bottom-full z-10 mb-2">
<ComposerCommandPopover
items={composerMenuItems}
triggerKind={composerTrigger.kind}
isLoading={pathSearch.isPending}
isLoading={
composerTrigger.kind === "skill"
? projectSkillsQuery.isPending
: pathSearch.isPending
}
onSelect={handleCommandSelect}
/>
</View>
Expand Down Expand Up @@ -783,7 +804,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
skills={selectedProviderSkills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
projectCwd={props.threadCwd}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListSkills]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4739,7 +4739,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("routes websocket rpc projects.listEntries and projects.readFile", () =>
it.effect("routes websocket rpc project file and skill reads", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -4749,6 +4749,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
path.join(workspaceDir, "src", "index.ts"),
"export const answer = 42;\n",
);
yield* fs.makeDirectory(path.join(workspaceDir, ".agents", "skills", "unslop"), {
recursive: true,
});
yield* fs.writeFileString(
path.join(workspaceDir, ".agents", "skills", "unslop", "SKILL.md"),
"---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n",
);

yield* buildAppUnderTest();

Expand All @@ -4757,6 +4764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
withWsRpcClient(wsUrl, (client) =>
Effect.all({
listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }),
skills: client[WS_METHODS.projectsListSkills]({ cwd: workspaceDir }),
file: client[WS_METHODS.projectsReadFile]({
cwd: workspaceDir,
relativePath: "src/index.ts",
Expand All @@ -4766,6 +4774,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);

assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts"));
assert.deepEqual(response.skills, [
{
name: "unslop",
description: "Remove AI writing patterns.",
path: path.join(workspaceDir, ".agents", "skills", "unslop", "SKILL.md"),
enabled: true,
scope: "project",
},
]);
assert.deepEqual(response.file, {
relativePath: "src/index.ts",
contents: "export const answer = 42;\n",
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,33 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
);
});

describe("listSkills", () => {
it.effect("discovers project skills under .agents/skills", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-agent-skills-" });
yield* writeTextFile(
cwd,
".agents/skills/unslop/SKILL.md",
"---\nname: unslop\ndescription: Remove AI writing patterns.\n---\n",
);

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const path = yield* Path.Path;
const skills = yield* workspaceEntries.listSkills({ cwd });

expect(skills).toEqual([
{
name: "unslop",
description: "Remove AI writing patterns.",
path: path.join(cwd, ".agents", "skills", "unslop", "SKILL.md"),
enabled: true,
scope: "project",
},
]);
}),
);
});

describe("search", () => {
it.effect("returns files and directories relative to cwd", () =>
Effect.gen(function* () {
Expand Down
66 changes: 65 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import type {
ProjectSearchContentsResult,
ProjectSearchEntriesInput,
ProjectSearchEntriesResult,
ServerProviderSkill,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
import { parse as parseYamlDocument } from "yaml";

import * as WorkspacePaths from "./WorkspacePaths.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
Expand Down Expand Up @@ -84,6 +86,32 @@ export const WorkspaceEntriesError = Schema.Union([
]);
export type WorkspaceEntriesError = typeof WorkspaceEntriesError.Type;

const SKILL_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;

function parseSkillFrontmatter(contents: string): {
readonly name?: string;
readonly description?: string;
} | null {
const match = SKILL_FRONTMATTER_PATTERN.exec(contents);
if (!match) return {};

let parsed: unknown;
try {
parsed = parseYamlDocument(match[1] ?? "");
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;

const record = parsed as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
return {
...(name ? { name } : {}),
...(description ? { description } : {}),
};
}

export class WorkspaceEntries extends Context.Service<
WorkspaceEntries,
{
Expand All @@ -93,6 +121,9 @@ export class WorkspaceEntries extends Context.Service<
readonly list: (
input: ProjectListEntriesInput,
) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
readonly listSkills: (input: {
readonly cwd: string;
}) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;
readonly search: (
input: ProjectSearchEntriesInput,
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
Expand Down Expand Up @@ -288,7 +319,40 @@ export const make = Effect.gen(function* () {
},
);

return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });
const listSkills: WorkspaceEntries["Service"]["listSkills"] = Effect.fn(
"WorkspaceEntries.listSkills",
)(function* ({ cwd }) {
const skillsRoot = path.join(cwd, ".agents", "skills");
const entries = yield* Effect.tryPromise(() => NodeFSP.readdir(skillsRoot)).pipe(
Effect.orElseSucceed((): string[] => []),
);
const skills: ServerProviderSkill[] = [];

for (const entry of entries.toSorted()) {
const skillPath = path.join(skillsRoot, entry, "SKILL.md");
const contents = yield* Effect.tryPromise(() => NodeFSP.readFile(skillPath, "utf8")).pipe(
Effect.orElseSucceed(() => undefined),
);
if (contents === undefined) continue;

const frontmatter = parseSkillFrontmatter(contents);
if (frontmatter === null) continue;
const name = frontmatter.name ?? entry.trim();
if (!name) continue;

skills.push({
name,
path: skillPath,
enabled: true,
scope: "project",
...(frontmatter.description ? { description: frontmatter.description } : {}),
});
}

return skills;
});

return WorkspaceEntries.of({ browse, list, listSkills, refresh, search, searchContents });
});

export const layer = Layer.effect(WorkspaceEntries, make).pipe(
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,10 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsListSkills]: (input) =>
observeRpcEffect(WS_METHODS.projectsListSkills, workspaceEntries.listSkills(input), {
"rpc.aggregate": "workspace",
}),
[WS_METHODS.projectsReadFile]: (input) =>
observeRpcEffect(
WS_METHODS.projectsReadFile,
Expand Down
49 changes: 33 additions & 16 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,15 @@ import type { SessionPhase, Thread } from "../../types";
import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
import type { PendingApproval, PendingUserInput } from "../../session-logic";
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
import {
formatProviderSkillDisplayName,
mergeProviderSkills,
} from "@t3tools/client-runtime/providerSkills";
import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import type { ReviewCommentContext } from "../../reviewCommentContext";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";

const runtimeModeConfig: Record<
RuntimeMode,
Expand Down Expand Up @@ -885,6 +890,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
() => selectedProviderEntry?.snapshot ?? null,
[selectedProviderEntry],
);
const projectSkillsQuery = useEnvironmentQuery(
selectedProvider === "codex" && gitCwd
? projectEnvironment.listSkills({
environmentId,
input: { cwd: gitCwd },
})
: null,
);
const selectedProviderSkills = useMemo(
() => mergeProviderSkills(selectedProviderStatus?.skills ?? [], projectSkillsQuery.data ?? []),
[projectSkillsQuery.data, selectedProviderStatus?.skills],
);
const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
() => selectedProviderEntry?.models ?? [],
[selectedProviderEntry],
Expand Down Expand Up @@ -1124,25 +1141,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return searchSlashCommandItems(slashCommandItems, query);
}
if (composerTrigger.kind === "skill") {
return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
(skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: formatProviderSkillDisplayName(skill),
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
}),
);
return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: formatProviderSkillDisplayName(skill),
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
}));
}
return [];
}, [
composerTrigger,
planModeUiEnabled,
selectedProvider,
selectedProviderSkills,
selectedProviderStatus,
workspaceEntries.entries,
]);
Expand Down Expand Up @@ -1209,7 +1225,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
]);

const isComposerMenuLoading =
composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending;
(composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) ||
(composerTriggerKind === "skill" && projectSkillsQuery.isPending);
const composerMenuEmptyState = useMemo(() => {
if (composerTriggerKind === "skill") {
return "No skills found. Try / to browse provider commands.";
Expand Down Expand Up @@ -3203,7 +3220,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? composerTerminalContexts
: []
}
skills={selectedProviderStatus?.skills ?? []}
skills={selectedProviderSkills}
{...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
onChange={onPromptChange}
Expand Down
Loading
Loading