Skip to content
Open
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
28 changes: 15 additions & 13 deletions apps/mobile/src/features/threads/ThreadComposer.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

{composerTrigger && composerMenuItems.length > 0 ? (

When the user types a skill trigger (e.g. $) or a slash-command query matching only provider commands, the popover never appears and the user sees nothing instead of the Loading… or No skills found. feedback. composerMenuItems is empty until useProviderProjectCapabilities resolves, and the render guard at line 695 checks composerMenuItems.length > 0 — so ComposerCommandPopover is never mounted and its loading/empty states are unreachable. The guard should also allow rendering when the trigger is active and the async lookup is still pending.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 695:

When the user types a skill trigger (e.g. `$`) or a slash-command query matching only provider commands, the popover never appears and the user sees nothing instead of the `Loading…` or `No skills found.` feedback. `composerMenuItems` is empty until `useProviderProjectCapabilities` resolves, and the render guard at line 695 checks `composerMenuItems.length > 0` — so `ComposerCommandPopover` is never mounted and its loading/empty states are unreachable. The guard should also allow rendering when the trigger is active and the async lookup is still pending.

Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
resolveProviderOptionDescriptors,
} from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { useProviderProjectCapabilities } from "../../state/queries";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";

/**
Expand Down Expand Up @@ -305,15 +306,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
});
const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";
const selectedProviderStatus = useMemo(() => {
if (!props.serverConfig) return null;
return (
props.serverConfig.providers.find(
(p) => p.instanceId === props.selectedThread.modelSelection.instanceId,
) ?? null
);
}, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);

// ── Trigger detection ────────────────────────────────────
const [composerSelection, setComposerSelection] = useState(() => ({
start: props.draftMessage.length,
Expand Down Expand Up @@ -346,6 +338,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
});
const providerProjectCapabilities = useProviderProjectCapabilities({
environmentId: props.environmentId,
providerInstanceId: props.selectedThread.modelSelection.instanceId,
cwd: props.projectCwd,
});

const composerMenuItems: ComposerCommandItem[] = useMemo(() => {
if (!composerTrigger) return [];
Expand Down Expand Up @@ -378,7 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const builtIn = allBuiltIn.filter((item) => item.command.includes(q));

const providerCommands: ComposerCommandItem[] = [];
for (const cmd of selectedProviderStatus?.slashCommands ?? []) {
for (const cmd of providerProjectCapabilities.slashCommands) {
if (!cmd.name.toLowerCase().includes(q)) continue;
providerCommands.push({
id: `pcmd:${cmd.name}`,
Expand All @@ -393,7 +390,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

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

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

// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
Expand Down Expand Up @@ -758,7 +760,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
ref={inputRef}
multiline
value={props.draftMessage}
skills={selectedProviderStatus?.skills ?? []}
skills={providerProjectCapabilities.skills}
selection={composerSelection}
onChangeText={props.onChangeDraftMessage}
onSelectionChange={handleSelectionChange}
Expand Down
19 changes: 10 additions & 9 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

When useProviderProjectCapabilities resolves after the feed is already visible, already-rendered messages keep using the old empty skills array instead of the newly loaded one. ThreadFeed passes props.skills into renderItem, but omits it from the extraData object that forces LegendList to repaint visible rows — and LegendList does not invalidate rows when only the render callback changes. So skill highlighting stays stale on existing messages until some unrelated list invalidation occurs.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadDetailScreen.tsx around line 320:

When `useProviderProjectCapabilities` resolves after the feed is already visible, already-rendered messages keep using the old empty `skills` array instead of the newly loaded one. `ThreadFeed` passes `props.skills` into `renderItem`, but omits it from the `extraData` object that forces `LegendList` to repaint visible rows — and `LegendList` does not invalidate rows when only the render callback changes. So skill highlighting stays stale on existing messages until some unrelated list invalidation occurs.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
} from "@t3tools/contracts";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
Expand All @@ -36,6 +36,7 @@ import type {
} from "../../lib/threadActivity";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { useProviderProjectCapabilities } from "../../state/queries";
import {
COMPOSER_COLLAPSED_CHROME,
COMPOSER_EXPANDED_CHROME,
Expand Down Expand Up @@ -271,12 +272,12 @@ 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);
const selectedProviderSkills = useMemo(
() =>
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
?.skills ?? [],
[props.serverConfig, selectedInstanceId],
);
const projectCapabilityCwd = props.threadCwd ?? props.projectWorkspaceRoot;
const selectedProviderCapabilities = useProviderProjectCapabilities({
environmentId: props.environmentId,
providerInstanceId: selectedInstanceId,
cwd: projectCapabilityCwd,
});

useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
Expand Down Expand Up @@ -415,7 +416,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
skills={selectedProviderSkills}
skills={selectedProviderCapabilities.skills}
/>
</View>
) : (
Expand Down Expand Up @@ -484,7 +485,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
projectCwd={projectCapabilityCwd}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
Expand Down
15 changes: 7 additions & 8 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/new-task-flow-provider.tsx:384

useProviderProjectCapabilities is called with cwd: selectedProject?.workspaceRoot || null, so skills are always loaded from the project's base checkout even when the draft selects a worktree path. Switching the new-task flow to worktree mode and choosing a branch in a different worktree leaves selectedProviderSkills reflecting the original checkout, so the composer surfaces the wrong repo-local skills for the task being created. Consider passing the draft's selectedWorktreePath (falling back to workspaceRoot) as the cwd argument so the capability query targets the same working directory the task will use.

  const selectedProviderCapabilities = useProviderProjectCapabilities({
    environmentId: selectedProject?.environmentId ?? null,
    providerInstanceId: selectedModel?.instanceId,
    cwd: selectedWorktreePath || selectedProject?.workspaceRoot || null,
  });
Also found in 1 other location(s)

apps/mobile/src/state/queries.ts:139

useProviderProjectCapabilities validates target.cwd with target.cwd.trim().length &gt; 0 but then sends the original untrimmed target.cwd at input.cwd. The RPC schema uses TrimmedNonEmptyString, whose encode/decode path trims whitespace, so repositories whose actual path starts or ends with spaces will be queried under a different path. In that case project capabilities are looked up in the wrong directory or fail entirely.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around lines 384-3889276348489318:

`useProviderProjectCapabilities` is called with `cwd: selectedProject?.workspaceRoot || null`, so skills are always loaded from the project's base checkout even when the draft selects a worktree path. Switching the new-task flow to worktree mode and choosing a branch in a different worktree leaves `selectedProviderSkills` reflecting the original checkout, so the composer surfaces the wrong repo-local skills for the task being created. Consider passing the draft's `selectedWorktreePath` (falling back to `workspaceRoot`) as the `cwd` argument so the capability query targets the same working directory the task will use.

Also found in 1 other location(s):
- apps/mobile/src/state/queries.ts:139 -- `useProviderProjectCapabilities` validates `target.cwd` with `target.cwd.trim().length > 0` but then sends the original untrimmed `target.cwd` at `input.cwd`. The RPC schema uses `TrimmedNonEmptyString`, whose encode/decode path trims whitespace, so repositories whose actual path starts or ends with spaces will be queried under a different path. In that case project capabilities are looked up in the wrong directory or fail entirely.

Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
updateComposerDraftSettings,
useComposerDraft,
} from "../../state/use-composer-drafts";
import { useBranches } from "../../state/queries";
import { useBranches, useProviderProjectCapabilities } from "../../state/queries";
import {
flattenQueuedThreadMessages,
threadOutboxManager,
Expand Down Expand Up @@ -381,13 +381,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
option.selection.instanceId === selectedModel.instanceId &&
option.selection.model === selectedModel.model,
) ?? null;
const selectedProviderSkills = useMemo(
() =>
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === selectedModel?.instanceId,
)?.skills ?? [],
[selectedEnvironmentServerConfig, selectedModel?.instanceId],
);
const selectedProviderCapabilities = useProviderProjectCapabilities({
environmentId: selectedProject?.environmentId ?? null,
providerInstanceId: selectedModel?.instanceId,
cwd: selectedProject?.workspaceRoot || null,
});
const selectedProviderSkills = selectedProviderCapabilities.skills;
const setSelectedModelKey = useCallback(
(key: string | null) => {
if (!key || !selectedProjectDraftKey) {
Expand Down
42 changes: 41 additions & 1 deletion apps/mobile/src/state/queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
import type {
EnvironmentId,
OrchestrationThread,
ProviderInstanceId,
ServerProviderSlashCommand,
ServerProviderSkill,
ThreadId,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import { useEffect, useMemo, useState } from "react";

Expand All @@ -16,6 +23,8 @@ import {
const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
const COMPOSER_PATH_SEARCH_LIMIT = 20;
const VCS_REF_LIST_LIMIT = 100;
const EMPTY_PROVIDER_SKILLS: ReadonlyArray<ServerProviderSkill> = [];
const EMPTY_PROVIDER_SLASH_COMMANDS: ReadonlyArray<ServerProviderSlashCommand> = [];

export interface ThreadDetailView {
readonly data: OrchestrationThread | null;
Expand Down Expand Up @@ -111,6 +120,37 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
};
}

export function useProviderProjectCapabilities(target: {
readonly environmentId: EnvironmentId | null;
readonly providerInstanceId: ProviderInstanceId | null | undefined;
readonly cwd: string | null | undefined;
}) {
const result = useEnvironmentQuery(
target.environmentId !== null &&
target.providerInstanceId !== null &&
target.providerInstanceId !== undefined &&
target.cwd !== null &&
target.cwd !== undefined &&
target.cwd.trim().length > 0
? projectEnvironment.projectCapabilities({
environmentId: target.environmentId,
input: {
providerInstanceId: target.providerInstanceId,
cwd: target.cwd,
},
})
: null,
);

return {
skills: result.data?.skills ?? EMPTY_PROVIDER_SKILLS,
slashCommands: result.data?.slashCommands ?? EMPTY_PROVIDER_SLASH_COMMANDS,
error: result.error,
isPending: result.isPending,
refresh: result.refresh,
};
}

export function useCheckpointDiff(target: CheckpointDiffTarget) {
const targets = useMemo(
() => buildCheckpointDiffTargets(target),
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
const PROJECT_CAPABILITIES_TTL = Duration.seconds(30);

function isClaudeNativeCommandPath(commandPath: string): boolean {
const normalized = normalizeCommandPath(commandPath);
Expand Down Expand Up @@ -159,6 +160,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig);
const projectCapabilitiesCache = yield* Cache.makeWith(
(cwd: string) =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
Effect.provideService(Path.Path, path),
),
{
capacity: 64,
timeToLive: () => PROJECT_CAPABILITIES_TTL,
},
);

const checkProvider = checkClaudeProviderStatus(
effectiveConfig,
Expand Down Expand Up @@ -212,6 +223,22 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
adapter,
textGeneration,
composerCapabilities: {
list: (input) =>
Effect.gen(function* () {
const capabilities = input.forceReload
? yield* probeClaudeCapabilities(effectiveConfig, processEnv, input.cwd).pipe(
Effect.provideService(Path.Path, path),
)
: yield* Cache.get(projectCapabilitiesCache, input.cwd);
return {
providerInstanceId: instanceId,
cwd: input.cwd,
slashCommands: capabilities?.slashCommands ?? [],
skills: capabilities?.skills ?? [],
};
}),
},
} satisfies ProviderInstance;
}),
};
49 changes: 48 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* @module provider/Drivers/CodexDriver
*/
import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import * as Cache from "effect/Cache";
import * as Duration from "effect/Duration";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand All @@ -36,7 +37,11 @@ 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,
listCodexProjectSkills,
makePendingCodexProvider,
} from "../Layers/CodexProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
Expand All @@ -61,6 +66,7 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings);

const DRIVER_KIND = ProviderDriverKind.make("codex");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
const PROJECT_CAPABILITIES_TTL = Duration.seconds(30);
const UPDATE = makePackageManagedProviderMaintenanceResolver({
provider: DRIVER_KIND,
npmPackageName: "@openai/codex",
Expand Down Expand Up @@ -161,6 +167,23 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
});
const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);
const projectSkillsCache = yield* Cache.makeWith(
(cwd: string) =>
listCodexProjectSkills({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
cwd,
environment: processEnv,
}).pipe(
Effect.scoped,
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.catchCause(() => Effect.succeed([])),
),
{
capacity: 64,
timeToLive: () => PROJECT_CAPABILITIES_TTL,
},
);

// Build a managed snapshot whose settings never change — mutations come
// in as instance rebuilds from the registry rather than in-place
Expand Down Expand Up @@ -209,6 +232,30 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
snapshot,
adapter,
textGeneration,
composerCapabilities: {
list: (input) =>
Effect.gen(function* () {
const skills = input.forceReload
? yield* listCodexProjectSkills({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
cwd: input.cwd,
forceReload: true,
environment: processEnv,
}).pipe(
Effect.scoped,
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.catchCause(() => Effect.succeed([])),
)
: yield* Cache.get(projectSkillsCache, input.cwd);
return {
providerInstanceId: instanceId,
cwd: input.cwd,
slashCommands: [],
skills,
};
}),
},
} satisfies ProviderInstance;
}),
};
Loading
Loading