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 });
}