Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 10 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ServerProviderSkill,
} from "@t3tools/contracts";
import {
detectComposerTrigger,
Expand Down Expand Up @@ -106,6 +107,12 @@ export interface ThreadComposerProps {
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
/**
* Skills offered by the `$` picker, resolved for the thread's workspace by
* the host screen. Not read off `serverConfig`: the provider snapshot's
* skills describe the server's own startup directory, not this project.
*/
readonly skills: ReadonlyArray<ServerProviderSkill>;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftImages: () => Promise<void>;
Expand Down Expand Up @@ -431,7 +438,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

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

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

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -775,7 +782,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
skills={props.skills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
18 changes: 16 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import type {
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { useWorkspaceSkills } from "../../state/queries";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
Expand Down Expand Up @@ -445,11 +446,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
// Skills belong to the thread's workspace, not to the server's startup cwd
// (which is all the provider snapshot can describe). `threadCwd` is the
// thread's worktree when it has one, else the project root — the same
// directory the agent will run in. The snapshot stays the fallback while the
// request is in flight or against a server that predates the RPC.
const workspaceSkills = useWorkspaceSkills({
environmentId: props.environmentId,
instanceId: selectedInstanceId,
cwd: props.threadCwd,
});
const selectedProviderSkills = useMemo(
() =>
workspaceSkills.skills ??
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
?.skills ?? [],
[props.serverConfig, selectedInstanceId],
?.skills ??
[],
[props.serverConfig, selectedInstanceId, workspaceSkills.skills],
);

useLayoutEffect(() => {
Expand Down Expand Up @@ -729,6 +742,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
skills={selectedProviderSkills}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
Expand Down
16 changes: 13 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
updateComposerDraftSettings,
useComposerDraft,
} from "../../state/use-composer-drafts";
import { useBranches } from "../../state/queries";
import { useBranches, useWorkspaceSkills } from "../../state/queries";
import {
flattenQueuedThreadMessages,
threadOutboxManager,
Expand Down Expand Up @@ -436,12 +436,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
option.selection.instanceId === selectedModel.instanceId &&
option.selection.model === selectedModel.model,
) ?? null;
// A draft has no worktree yet, so its workspace is the selected project's
// root. Asking the server for that directory's skills beats the provider
// snapshot, whose skills belong to the server's own startup cwd.
const workspaceSkills = useWorkspaceSkills({
environmentId: selectedProject?.environmentId ?? null,
instanceId: selectedModel?.instanceId ?? null,
cwd: selectedProject?.workspaceRoot || null,
});
const selectedProviderSkills = useMemo(
() =>
workspaceSkills.skills ??
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === selectedModel?.instanceId,
)?.skills ?? [],
[selectedEnvironmentServerConfig, selectedModel?.instanceId],
)?.skills ??
[],
[selectedEnvironmentServerConfig, selectedModel?.instanceId, workspaceSkills.skills],
);
const setSelectedModelKey = useCallback(
// Options ride along in the same write: a follow-up setSelectedModelOptions
Expand Down
46 changes: 45 additions & 1 deletion apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationThread,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import {
createThreadSearchResultsAtomFamily,
makeThreadSearchKey,
Expand All @@ -12,6 +17,7 @@ import { useEffect, useMemo, useState } from "react";
import { orchestrationEnvironment } from "./orchestration";
import { projectEnvironment } from "./projects";
import { useEnvironmentQuery } from "./query";
import { serverEnvironment } from "./server";
import { useEnvironmentThread } from "./threads";
import { vcsEnvironment } from "./vcs";
import {
Expand Down Expand Up @@ -125,6 +131,44 @@ export function useBranches(input: {
);
}

/**
* Skills for one workspace, asked of the server per request.
*
* The provider snapshot in `serverConfig` carries a `skills` list, but the
* server discovers it once against its own startup cwd, so it belongs to
* whichever directory the server was launched from rather than the project
* being viewed. Resolves to `null` until the server answers (and if it never
* does), which lets callers fall back to the snapshot instead of blanking the
* picker.
*/
export function useWorkspaceSkills(input: {
readonly environmentId: EnvironmentId | null;
readonly instanceId: ProviderInstanceId | null;
readonly cwd: string | null;
}) {
// A stand-in project carries an empty workspaceRoot, which the request
// schema rejects — treat it like "no workspace" rather than issuing a call
// that can only fail.
const cwd = input.cwd?.trim() ? input.cwd : null;
const result = useEnvironmentQuery(
input.environmentId !== null && cwd !== null
? serverEnvironment.workspaceSkills({
environmentId: input.environmentId,
input: {
...(input.instanceId ? { instanceId: input.instanceId } : {}),
cwd,
},
})
: null,
);

return {
skills: result.data?.skills ?? null,
error: result.error,
isPending: result.isPending,
};
}

export function useComposerPathSearch(target: ComposerPathSearchTarget) {
const normalizedTarget = useMemo(
() => ({
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
// Enumerating a workspace's skills is a read of the same provider state the
// config snapshot already exposes to read-only clients.
[WS_METHODS.serverListWorkspaceSkills]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeWorkspaceSkillsCache } from "../workspaceSkills.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts";
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -163,6 +165,18 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);

// Workspace-scoped skill discovery. The snapshot's `skills` list is
// scanned once against the server's own cwd, which is a single global
// directory chosen at startup; the picker needs the skills of whichever
// project the client is looking at, so the cwd arrives per call and the
// results are cached per workspace.
const listWorkspaceSkills = yield* makeWorkspaceSkillsCache((workspaceCwd) =>
discoverClaudeSkills(effectiveConfig, workspaceCwd, processEnv).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
);

const checkProvider = checkClaudeProviderStatus(
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
Expand Down Expand Up @@ -216,6 +230,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
adapter,
textGeneration,
listWorkspaceSkills,
} satisfies ProviderInstance;
}),
};
38 changes: 31 additions & 7 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,24 +106,48 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*

const skillsByName = new Map<string, ServerProviderSkill>();
for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
// A missing skills directory is the common case (most users have no
// project skills), so this stays at debug — but it is logged, because an
// unreadable directory and an empty picker are otherwise indistinguishable.
const entries = yield* fileSystem.readDirectory(root.directory).pipe(
Effect.tapError((cause) =>
Effect.logDebug("claude skill root is not readable", {
directory: root.directory,
scope: root.scope,
cause,
}),
),
Effect.orElseSucceed((): ReadonlyArray<string> => []),
);

for (const entry of [...entries].sort()) {
const skillPath = path.join(root.directory, entry, "SKILL.md");
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
const contents = yield* fileSystem.readFileString(skillPath).pipe(
Effect.tapError((cause) =>
// Expected for non-skill entries (a stray `README.md`, a directory
// without `SKILL.md`); a permission error lands here too.
Effect.logDebug("claude skill entry has no readable SKILL.md", {
path: skillPath,
scope: root.scope,
cause,
}),
),
Effect.orElseSucceed(() => undefined),
);
if (contents === undefined) {
continue;
}

const frontmatter = parseSkillFrontmatter(contents);
// Malformed frontmatter means the skill won't load in Claude Code
// either — skip it rather than surfacing a broken entry under its
// directory name.
// directory name. Unlike the cases above this is a real authoring
// mistake, so it warns: the user wrote a skill that nothing will load.
if (frontmatter.kind === "malformed") {
yield* Effect.logWarning("claude skill has malformed YAML frontmatter; skipping", {
path: skillPath,
scope: root.scope,
});
continue;
}

Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
import {
checkCodexProviderStatus,
listCodexWorkspaceSkills,
makePendingCodexProvider,
} from "../Layers/CodexProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import { makeWorkspaceSkillsCache } from "../workspaceSkills.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makePackageManagedProviderMaintenanceResolver,
Expand Down Expand Up @@ -170,6 +175,16 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
// Workspace-scoped skill discovery. The snapshot probe asks the
// app-server about the server's own cwd — one global directory picked at
// startup — so the picker would otherwise show another project's skills.
// Each lookup spawns an app-server, hence the per-workspace cache.
const listWorkspaceSkills = yield* makeWorkspaceSkillsCache((workspaceCwd) =>
listCodexWorkspaceSkills(effectiveConfig, workspaceCwd, processEnv).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
maintenanceCapabilities,
Expand Down Expand Up @@ -208,6 +223,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
snapshot,
adapter,
textGeneration,
listWorkspaceSkills,
} satisfies ProviderInstance;
}),
};
Loading
Loading