diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..82b6b9617c1b0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -934,11 +934,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedProviderSlashCommands = selectedProviderStatus ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) : NO_PROVIDER_SLASH_COMMANDS; - const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ - environmentId, - gitCwd, - instanceId: selectedProviderEntry?.instanceId, - }); + const { refresh: refreshWorkspaceSnapshotOnTyping, scanning: workspaceSkillsScanning } = + useProviderWorkspaceSnapshotRefresh({ + environmentId, + gitCwd, + instanceId: selectedProviderEntry?.instanceId, + }); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -1139,6 +1140,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + workspaceSkillsScanning, questionAttachmentTarget, }); @@ -3289,7 +3291,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isComposerMenuLoading ? composerTriggerKind === "pull-request" ? "Finding pull request..." - : "Searching workspace files..." + : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" + ? "Searching workspace skills..." + : "Searching workspace files..." : composerMenuEmptyState : ""} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index afa058587b553..0b9dda460d8ef 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -152,7 +152,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

{props.isLoading - ? props.triggerKind === "skill" + ? props.triggerKind === "skill" || props.triggerKind === "slash-command" ? "Searching workspace skills..." : props.triggerKind === "pull-request" ? "Finding pull request..." diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 3ae947ff897f9..c9870f8318a26 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -71,6 +71,7 @@ export function useComposerMenuItems({ selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu, + workspaceSkillsScanning, questionAttachmentTarget, }: { readonly composerTrigger: ComposerTrigger | null; @@ -89,6 +90,7 @@ export function useComposerMenuItems({ readonly selectedProviderSkills: ServerProvider["skills"]; readonly selectedProviderSlashCommands: ServerProvider["slashCommands"]; readonly showSkillsInSlashMenu: boolean; + readonly workspaceSkillsScanning: boolean; readonly questionAttachmentTarget: DraftId | null; }) { // Thread mentions rank by recency as of when the composer mounted. @@ -373,6 +375,8 @@ export function useComposerMenuItems({ })(); const isLoading = (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + ((composerTriggerKind === "skill" || composerTriggerKind === "slash-command") && + workspaceSkillsScanning) || (composerTriggerKind === "pull-request" && pullRequestProjectId !== null && pullRequestRepository !== null && diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index 8d56ed11c64a2..d24ddb727c7bc 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -1,6 +1,10 @@ -import { useAtomRefresh } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, type ProviderInstanceId } from "@t3tools/contracts"; -import { createProviderWorkspaceSnapshotRefreshAtomFamily } from "@t3tools/client-runtime/providerSkills"; +import { + createProviderWorkspaceSnapshotRefreshAtomFamily, + isProviderWorkspaceSnapshotScanning, + type ProviderWorkspaceSnapshotRefreshState, +} from "@t3tools/client-runtime/providerSkills"; import { Atom } from "effect/reactivity"; import { serverEnvironment } from "../../state/server"; @@ -10,23 +14,27 @@ const providerWorkspaceSnapshotRefresh = createProviderWorkspaceSnapshotRefreshA refreshProviders: serverEnvironment.refreshProviders, }); -const idleRefreshAtom: Atom.Atom = Atom.make(undefined).pipe( - Atom.withLabel("provider-workspace-snapshot-refresh:idle"), -); +const idleRefreshAtom: Atom.Atom = Atom.make< + ProviderWorkspaceSnapshotRefreshState | undefined +>(undefined).pipe(Atom.withLabel("provider-workspace-snapshot-refresh:idle")); /** * Keeps the selected provider's workspace snapshot fresh for the composer's - * cwd while mounted. Returns a check to run when the user types. + * cwd while mounted. Returns a check to run when the user types, and whether + * the cwd's skills are still being scanned. */ export function useProviderWorkspaceSnapshotRefresh(input: { readonly environmentId: EnvironmentId; readonly gitCwd: string | null; readonly instanceId: ProviderInstanceId | undefined; -}): () => void { +}): { readonly refresh: () => void; readonly scanning: boolean } { const { environmentId, gitCwd, instanceId } = input; const atom = gitCwd && instanceId ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId, cwd: gitCwd }) : idleRefreshAtom; - return useAtomRefresh(atom); + return { + refresh: useAtomRefresh(atom), + scanning: isProviderWorkspaceSnapshotScanning(useAtomValue(atom)), + }; } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index ccc5bf6ef2807..bd5729656c841 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -12,6 +12,7 @@ import { createProviderWorkspaceSnapshotRefreshAtomFamily, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + isProviderWorkspaceSnapshotScanning, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "./providerSkills.ts"; @@ -298,6 +299,53 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it("reports skills loading while a missing snapshot is scanned, until it arrives", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const scanned = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, providersAtom, unmount } = setup(noSnapshot, [() => scanned]); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + registry.set(providersAtom, scanned); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("stops reporting skills loading when the scan fails", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, unmount } = setup(noSnapshot); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("keeps reporting skills loading while the scan returns a pending snapshot", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const pending = withSnapshot("2026-01-01T00:00:00.000Z", true); + const { registry, atom, providersAtom, unmount } = setup(pending, [() => pending]); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + registry.set(providersAtom, withSnapshot("2026-01-01T00:00:00.000Z")); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("stops reporting skills loading once a pending snapshot exhausts its retries", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true)); + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(3); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("does not report skills loading for a current snapshot", () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); + const { registry, atom, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + it("does not retry on a timer when the failed scan left no pending snapshot", async () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); const { registry, atom, run, unmount } = setup(noSnapshot); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 63ded7a47dc5c..2598c895c5053 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -136,7 +136,7 @@ const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; // A cwd that never scans, such as a deleted worktree, would otherwise retry forever. const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; -interface ProviderWorkspaceSnapshotRefreshState { +export interface ProviderWorkspaceSnapshotRefreshState { /** * When the last scan was requested. A request inside the TTL is not * repeated, so a client clock ahead of the server's cannot loop rescans. @@ -147,6 +147,8 @@ interface ProviderWorkspaceSnapshotRefreshState { /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; + /** The server published a partial snapshot and is still discovering. */ + readonly snapshotPending: boolean; } const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRefreshState = { @@ -154,6 +156,7 @@ const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRef retryNotBefore: null, failedAttempts: 0, hadSnapshot: false, + snapshotPending: false, }; /** @@ -172,6 +175,8 @@ function planProviderWorkspaceSnapshotRefresh( readonly recheckAt: number | null; } { const hasSnapshot = hasCompleteProviderWorkspaceSnapshot(provider, cwd); + const pending = + (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; // Losing a snapshot (the server dropped it) lifts both the TTL and the retry // cooldown so the scan happens at once. const lost = previous.hadSnapshot && !hasSnapshot; @@ -188,6 +193,7 @@ function planProviderWorkspaceSnapshotRefresh( ? 0 : previous.failedAttempts, hadSnapshot: hasSnapshot, + snapshotPending: pending, }; const refresh = (requestedAt === null || now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) && @@ -196,8 +202,6 @@ function planProviderWorkspaceSnapshotRefresh( if (refresh) { return { state: { ...state, requestedAt: now }, refresh: true, recheckAt: null }; } - const pending = - (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; return { state, refresh: false, recheckAt: pending ? retryNotBefore : null }; } @@ -223,6 +227,23 @@ function failProviderWorkspaceSnapshotRefresh( }; } +/** + * A scan is in flight, or the server is still discovering, for a cwd without a + * complete snapshot, so its skills and commands may still arrive. A failed scan + * clears `requestedAt`, and exhausted attempts end it, so this cannot stay true + * for a scan that will not finish. + */ +export function isProviderWorkspaceSnapshotScanning( + state: ProviderWorkspaceSnapshotRefreshState | undefined, +): boolean { + return ( + state !== undefined && + (state.requestedAt !== null || state.snapshotPending) && + !state.hadSnapshot && + state.failedAttempts < WORKSPACE_SNAPSHOT_MAX_ATTEMPTS + ); +} + type WorkspaceSnapshotStatus = "absent" | "pending" | "complete"; function resolveWorkspaceSnapshotStatus( @@ -330,6 +351,8 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { ).pipe(Atom.withLabel(`provider-workspace-snapshot-refresh:${key}`)); return refreshAtom; }); - return (target: ProviderWorkspaceSnapshotRefreshTarget): Atom.Atom => + return ( + target: ProviderWorkspaceSnapshotRefreshTarget, + ): Atom.Atom => family({ environmentId: target.environmentId, instanceId: target.instanceId, cwd: target.cwd }); }