| author | |
| committer | |
| log | a1830915ecf70e3ad4fbe8efb15ae9f18517eb54 |
| tree | 2d8b57669d0ead856dad2442527a3f372227fec5 |
| parent | 4b063a042ec93418a196c020b852459f57e17b57 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 126 insertions(+), 34 deletions(-)
apps/mobile/src/features/threads/use-composer-command-menu.ts+22-12| ... | ... | @@ -9,7 +9,10 @@ import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThread |
| 9 | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 10 | 10 | |
| 11 | 11 | const EMPTY_THREAD_SHELLS: ReadonlyArray<EnvironmentThreadShell> = []; |
| 12 | import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; | |
| 12 | import { | |
| 13 | COMPOSER_CONTEXT_MAX_RECORDS, | |
| 14 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | |
| 15 | } from "@t3tools/contracts"; | |
| 13 | 16 | import { Alert } from "react-native"; |
| 14 | 17 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; |
| 15 | 18 | import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; |
| ... | ... | @@ -37,6 +40,7 @@ import { |
| 37 | 40 | getProviderSlashCommandsForSlashMenu, |
| 38 | 41 | isProviderSkillUserInvocable, |
| 39 | 42 | hasCompleteProviderWorkspaceSnapshot, |
| 43 | hasCurrentProviderWorkspaceSnapshot, | |
| 40 | 44 | resolveProviderSkillsForCwd, |
| 41 | 45 | resolveProviderSlashCommandsForCwd, |
| 42 | 46 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | ... | @@ -250,7 +254,9 @@ export function useComposerCommandMenu({ |
| 250 | 254 | selectedProviderStatus, |
| 251 | 255 | projectCwd, |
| 252 | 256 | ); |
| 253 | const workspaceRefreshKeyRef = useRef<string | null>(null); | |
| 257 | // The last scan this composer asked for. A request inside the TTL is not | |
| 258 | // repeated, so a client clock ahead of the server's cannot loop rescans. | |
| 259 | const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); | |
| 254 | 260 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 255 | 261 | key: string; |
| 256 | 262 | notBefore: number; |
| ... | ... | @@ -289,17 +295,23 @@ export function useComposerCommandMenu({ |
| 289 | 295 | useEffect(() => { |
| 290 | 296 | if (!environmentId || !projectCwd || !selectedProviderInstanceId) return; |
| 291 | 297 | const key = `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`; |
| 292 | if (workspaceRefreshKeyRef.current === key) return; | |
| 293 | if (hasWorkspaceSnapshot) { | |
| 294 | workspaceRefreshKeyRef.current = key; | |
| 298 | const now = Date.now(); | |
| 299 | const lastRequest = workspaceRefreshKeyRef.current; | |
| 300 | if ( | |
| 301 | lastRequest?.key === key && | |
| 302 | now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS | |
| 303 | ) | |
| 304 | return; | |
| 305 | if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, projectCwd, now)) { | |
| 295 | 306 | setWorkspaceRefreshRetry(null); |
| 296 | 307 | return; |
| 297 | 308 | } |
| 298 | 309 | const retry = workspaceRefreshRetry; |
| 299 | if (retry?.key === key && Date.now() < retry.notBefore) return; | |
| 300 | workspaceRefreshKeyRef.current = key; | |
| 310 | if (retry?.key === key && now < retry.notBefore) return; | |
| 311 | const request = { key, requestedAt: now }; | |
| 312 | workspaceRefreshKeyRef.current = request; | |
| 301 | 313 | const retryLater = () => { |
| 302 | if (workspaceRefreshKeyRef.current !== key) return; | |
| 314 | if (workspaceRefreshKeyRef.current !== request) return; | |
| 303 | 315 | workspaceRefreshKeyRef.current = null; |
| 304 | 316 | setWorkspaceRefreshRetry({ |
| 305 | 317 | key, |
| ... | ... | @@ -318,17 +330,15 @@ export function useComposerCommandMenu({ |
| 318 | 330 | ), |
| 319 | 331 | projectCwd, |
| 320 | 332 | ); |
| 321 | if (!refreshed && workspaceRefreshKeyRef.current === key) { | |
| 322 | retryLater(); | |
| 323 | } | |
| 333 | if (!refreshed) retryLater(); | |
| 324 | 334 | }, retryLater); |
| 325 | 335 | }, [ |
| 326 | 336 | draftMessage, |
| 327 | 337 | environmentId, |
| 328 | hasWorkspaceSnapshot, | |
| 329 | 338 | projectCwd, |
| 330 | 339 | refreshProviders, |
| 331 | 340 | selectedProviderInstanceId, |
| 341 | selectedProviderStatus, | |
| 332 | 342 | workspaceRefreshRetry, |
| 333 | 343 | ]); |
| 334 | 344 |
apps/server/src/provider/ProviderRegistry.test.ts+9-1| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | ClaudeSettings, |
| 24 | 24 | CodexSettings, |
| 25 | 25 | DEFAULT_SERVER_SETTINGS, |
| 26 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | |
| 26 | 27 | ProviderDriverKind, |
| 27 | 28 | ProviderInstanceId, |
| 28 | 29 | ServerSettings, |
| ... | ... | @@ -1768,6 +1769,13 @@ it.layer( |
| 1768 | 1769 | ); |
| 1769 | 1770 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); |
| 1770 | 1771 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); |
| 1772 | // Nothing watches skill directories, so an expired scan is redone on use. | |
| 1773 | yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); | |
| 1774 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | |
| 1775 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | |
| 1776 | yield* TestClock.adjust(1); | |
| 1777 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | |
| 1778 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | |
| 1771 | 1779 | const newSkills = [ |
| 1772 | 1780 | ...scopedProvider.skills, |
| 1773 | 1781 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, |
| ... | ... | @@ -1778,7 +1786,7 @@ it.layer( |
| 1778 | 1786 | cwd: "/workspace", |
| 1779 | 1787 | fresh: true, |
| 1780 | 1788 | }); |
| 1781 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | |
| 1789 | assert.strictEqual(yield* Ref.get(snapshotCalls), 5); | |
| 1782 | 1790 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); |
| 1783 | 1791 | assert.deepStrictEqual( |
| 1784 | 1792 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), |
apps/server/src/provider/ProviderRegistry.ts+14-2| ... | ... | @@ -29,6 +29,7 @@ |
| 29 | 29 | */ |
| 30 | 30 | import { |
| 31 | 31 | defaultInstanceIdForDriver, |
| 32 | isProviderWorkspaceSnapshotCurrent, | |
| 32 | 33 | ProviderDriverKind, |
| 33 | 34 | type ProviderInstanceId, |
| 34 | 35 | type ServerProvider, |
| ... | ... | @@ -36,6 +37,7 @@ import { |
| 36 | 37 | } from "@t3tools/contracts"; |
| 37 | 38 | import * as Cause from "effect/Cause"; |
| 38 | 39 | import * as Context from "effect/Context"; |
| 40 | import * as DateTime from "effect/DateTime"; | |
| 39 | 41 | import * as Deferred from "effect/Deferred"; |
| 40 | 42 | import * as Effect from "effect/Effect"; |
| 41 | 43 | import * as Equal from "effect/Equal"; |
| ... | ... | @@ -1048,13 +1050,20 @@ export const layer = Layer.effect( |
| 1048 | 1050 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => |
| 1049 | 1051 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); |
| 1050 | 1052 | const scannedFrom = workspaceSnapshotOf(provider); |
| 1053 | const now = yield* DateTime.now; | |
| 1051 | 1054 | if ( |
| 1052 | 1055 | !provider || |
| 1053 | 1056 | !provider.enabled || |
| 1054 | (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) | |
| 1057 | (!input.fresh && | |
| 1058 | scannedFrom && | |
| 1059 | !scannedFrom.slashCommandsPending && | |
| 1060 | isProviderWorkspaceSnapshotCurrent(scannedFrom, DateTime.toEpochMillis(now))) | |
| 1055 | 1061 | ) { |
| 1056 | 1062 | return providers; |
| 1057 | 1063 | } |
| 1064 | // Drivers spread their machine snapshot, whose `checkedAt` is the last | |
| 1065 | // health check. The TTL needs the time this scan started reading files. | |
| 1066 | const scannedAt = DateTime.formatIso(now); | |
| 1058 | 1067 | const instance = yield* instanceRegistry.getInstance(input.instanceId); |
| 1059 | 1068 | if (!instance?.snapshotForCwd) return providers; |
| 1060 | 1069 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { |
| ... | ... | @@ -1086,7 +1095,10 @@ export const layer = Layer.effect( |
| 1086 | 1095 | currentProviders.map((candidate) => |
| 1087 | 1096 | candidate.instanceId === input.instanceId && |
| 1088 | 1097 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) |
| 1089 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | |
| 1098 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { | |
| 1099 | ...scopedSnapshot, | |
| 1100 | checkedAt: scannedAt, | |
| 1101 | }) | |
| 1090 | 1102 | : candidate, |
| 1091 | 1103 | ), |
| 1092 | 1104 | ); |
apps/web/src/components/chat/ChatComposer.tsx+18-14| ... | ... | @@ -44,6 +44,7 @@ import { |
| 44 | 44 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 45 | 45 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 46 | 46 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 47 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | |
| 47 | 48 | } from "@t3tools/contracts"; |
| 48 | 49 | import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 49 | 50 | import { |
| ... | ... | @@ -1123,6 +1124,7 @@ import { |
| 1123 | 1124 | getProviderSlashCommandsForSlashMenu, |
| 1124 | 1125 | getProviderSkillsForSlashMenu, |
| 1125 | 1126 | hasCompleteProviderWorkspaceSnapshot, |
| 1127 | hasCurrentProviderWorkspaceSnapshot, | |
| 1126 | 1128 | resolveProviderSkillsForCwd, |
| 1127 | 1129 | resolveProviderSlashCommandsForCwd, |
| 1128 | 1130 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | ... | @@ -2194,7 +2196,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2194 | 2196 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 2195 | 2197 | reportFailure: false, |
| 2196 | 2198 | }); |
| 2197 | const workspaceRefreshKeyRef = useRef<string | null>(null); | |
| 2199 | // The last scan this composer asked for. A request inside the TTL is not | |
| 2200 | // repeated, so a client clock ahead of the server's cannot loop rescans. | |
| 2201 | const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); | |
| 2198 | 2202 | const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ |
| 2199 | 2203 | key: string; |
| 2200 | 2204 | notBefore: number; |
| ... | ... | @@ -2237,21 +2241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2237 | 2241 | useEffect(() => { |
| 2238 | 2242 | if (!gitCwd || !selectedProviderEntry) return; |
| 2239 | 2243 | const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; |
| 2240 | const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot( | |
| 2241 | selectedProviderStatus, | |
| 2242 | gitCwd, | |
| 2243 | ); | |
| 2244 | if (workspaceRefreshKeyRef.current === key) return; | |
| 2245 | if (hasWorkspaceSnapshot) { | |
| 2246 | workspaceRefreshKeyRef.current = key; | |
| 2244 | const now = Date.now(); | |
| 2245 | const lastRequest = workspaceRefreshKeyRef.current; | |
| 2246 | if ( | |
| 2247 | lastRequest?.key === key && | |
| 2248 | now - lastRequest.requestedAt < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS | |
| 2249 | ) | |
| 2250 | return; | |
| 2251 | if (hasCurrentProviderWorkspaceSnapshot(selectedProviderStatus, gitCwd, now)) { | |
| 2247 | 2252 | setWorkspaceRefreshRetry(null); |
| 2248 | 2253 | return; |
| 2249 | 2254 | } |
| 2250 | 2255 | const retry = workspaceRefreshRetry; |
| 2251 | if (retry?.key === key && Date.now() < retry.notBefore) return; | |
| 2252 | workspaceRefreshKeyRef.current = key; | |
| 2256 | if (retry?.key === key && now < retry.notBefore) return; | |
| 2257 | const request = { key, requestedAt: now }; | |
| 2258 | workspaceRefreshKeyRef.current = request; | |
| 2253 | 2259 | const retryLater = () => { |
| 2254 | if (workspaceRefreshKeyRef.current !== key) return; | |
| 2260 | if (workspaceRefreshKeyRef.current !== request) return; | |
| 2255 | 2261 | workspaceRefreshKeyRef.current = null; |
| 2256 | 2262 | setWorkspaceRefreshRetry({ |
| 2257 | 2263 | key, |
| ... | ... | @@ -2270,9 +2276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2270 | 2276 | ), |
| 2271 | 2277 | gitCwd, |
| 2272 | 2278 | ); |
| 2273 | if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { | |
| 2274 | retryLater(); | |
| 2275 | } | |
| 2279 | if (!hasWorkspaceSnapshot) retryLater(); | |
| 2276 | 2280 | }, retryLater); |
| 2277 | 2281 | }, [ |
| 2278 | 2282 | environmentId, |
packages/client-runtime/src/providerSkills.test.ts+30-1| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | |
| 3 | ProviderDriverKind, | |
| 4 | ProviderInstanceId, | |
| 5 | type ServerProvider, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 8 | |
| 4 | 9 | import { |
| ... | ... | @@ -7,6 +12,7 @@ import { |
| 7 | 12 | getProviderSlashCommandsForSlashMenu, |
| 8 | 13 | getProviderSkillsForSlashMenu, |
| 9 | 14 | hasCompleteProviderWorkspaceSnapshot, |
| 15 | hasCurrentProviderWorkspaceSnapshot, | |
| 10 | 16 | resolveProviderSkillsForCwd, |
| 11 | 17 | resolveProviderSlashCommandsForCwd, |
| 12 | 18 | resolveProviderSkillSourceKind, |
| ... | ... | @@ -274,4 +280,27 @@ describe("workspace provider snapshots", () => { |
| 274 | 280 | expect(hasCompleteProviderWorkspaceSnapshot(undefined, "/workspace/project-a")).toBe(false); |
| 275 | 281 | expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); |
| 276 | 282 | }); |
| 283 | ||
| 284 | it("asks for a rescan once the workspace snapshot outlives its TTL", () => { | |
| 285 | const scannedAt = Date.parse("2026-01-01T00:01:00.000Z"); | |
| 286 | const cwd = "/workspace/project-a"; | |
| 287 | expect(hasCurrentProviderWorkspaceSnapshot(provider, cwd, scannedAt)).toBe(true); | |
| 288 | expect( | |
| 289 | hasCurrentProviderWorkspaceSnapshot( | |
| 290 | provider, | |
| 291 | cwd, | |
| 292 | scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1, | |
| 293 | ), | |
| 294 | ).toBe(true); | |
| 295 | expect( | |
| 296 | hasCurrentProviderWorkspaceSnapshot( | |
| 297 | provider, | |
| 298 | cwd, | |
| 299 | scannedAt + PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS, | |
| 300 | ), | |
| 301 | ).toBe(false); | |
| 302 | expect(hasCurrentProviderWorkspaceSnapshot(provider, "/workspace/project-b", scannedAt)).toBe( | |
| 303 | false, | |
| 304 | ); | |
| 305 | }); | |
| 277 | 306 | }); |
packages/client-runtime/src/providerSkills.ts+19-4| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import type { | |
| 2 | ServerProvider, | |
| 3 | ServerProviderSkill, | |
| 4 | ServerProviderSlashCommand, | |
| 1 | import { | |
| 2 | isProviderWorkspaceSnapshotCurrent, | |
| 3 | type ServerProvider, | |
| 4 | type ServerProviderSkill, | |
| 5 | type ServerProviderSlashCommand, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | |
| 7 | 8 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| ... | ... | @@ -119,6 +120,20 @@ export function hasCompleteProviderWorkspaceSnapshot( |
| 119 | 120 | return Boolean(snapshot && !snapshot.slashCommandsPending); |
| 120 | 121 | } |
| 121 | 122 | |
| 123 | /** A complete snapshot young enough that opening a composer need not rescan. */ | |
| 124 | export function hasCurrentProviderWorkspaceSnapshot( | |
| 125 | provider: ServerProvider | null | undefined, | |
| 126 | cwd: string | null | undefined, | |
| 127 | nowMs: number, | |
| 128 | ): boolean { | |
| 129 | const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); | |
| 130 | return Boolean( | |
| 131 | snapshot && | |
| 132 | !snapshot.slashCommandsPending && | |
| 133 | isProviderWorkspaceSnapshotCurrent(snapshot, nowMs), | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 122 | 137 | export function resolveProviderSkillsForCwd( |
| 123 | 138 | provider: ServerProvider, |
| 124 | 139 | cwd: string | null | undefined, |
packages/contracts/src/server.ts+14| ... | ... | @@ -131,6 +131,20 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ |
| 131 | 131 | }); |
| 132 | 132 | export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; |
| 133 | 133 | |
| 134 | /** | |
| 135 | * How long a workspace's skill and command scan stays current. Nothing watches | |
| 136 | * skill directories, so a composer opened after this rescans on use, and the | |
| 137 | * server answers repeat requests inside the window from its cache. | |
| 138 | */ | |
| 139 | export const PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS = 5 * 60_000; | |
| 140 | ||
| 141 | export function isProviderWorkspaceSnapshotCurrent( | |
| 142 | snapshot: Pick<ServerProviderWorkspaceSnapshot, "checkedAt">, | |
| 143 | nowMs: number, | |
| 144 | ): boolean { | |
| 145 | return nowMs - Date.parse(snapshot.checkedAt) < PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS; | |
| 146 | } | |
| 147 | ||
| 134 | 148 | /** |
| 135 | 149 | * Availability of a configured provider instance from the runtime's POV. |
| 136 | 150 | * |