Unified · Split
| @@ -72,7 +72,10 @@ import { · 71 unmodified lines | ||
| 72 | 72 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 73 | 73 | import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; |
| 74 | 74 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 75 | −import { buildMobilePluginCommandItems } from "./plugin-command-menu"; | |
| 75 | +import { | |
| 76 | + buildMobilePluginCommandItems, | |
| 77 | + reconcileComposerSelectionForTextChange, | |
| 78 | +} from "./plugin-command-menu"; | |
| 76 | 79 | import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; |
| 77 | 80 | |
| 78 | 81 | function NewTaskWorkspaceIcon(props: { |
| @@ -138,6 +141,7 @@ export function NewTaskDraftScreen(props: { · 59 unmodified lines | ||
| 138 | 141 | start: flow.prompt.length, |
| 139 | 142 | end: flow.prompt.length, |
| 140 | 143 | })); |
| 144 | + const previousPromptLengthRef = useRef(flow.prompt.length); | |
| 141 | 145 | const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( |
| 142 | 146 | selectedProject?.environmentId ?? null, |
| 143 | 147 | ); |
| @@ -169,13 +173,11 @@ export function NewTaskDraftScreen(props: { · 25 unmodified lines | ||
| 169 | 173 | ); |
| 170 | 174 | useEffect(() => { |
| 171 | 175 | const end = flow.prompt.length; |
| 172 | − setComposerSelection((selection) => { | |
| 173 | − const start = Math.min(selection.start, end); | |
| 174 | − const selectionEnd = Math.min(selection.end, end); | |
| 175 | − return start === selection.start && selectionEnd === selection.end | |
| 176 | − ? selection | |
| 177 | − : { start, end: selectionEnd }; | |
| 178 | − }); | |
| 176 | + const previousEnd = previousPromptLengthRef.current; | |
| 177 | + previousPromptLengthRef.current = end; | |
| 178 | + setComposerSelection((selection) => | |
| 179 | + reconcileComposerSelectionForTextChange(selection, previousEnd, end), | |
| 180 | + ); | |
| 179 | 181 | }, [flow.prompt.length]); |
| 180 | 182 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 181 | 183 | editorRef: promptInputRef, |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | −import { buildMobilePluginCommandItems } from "./plugin-command-menu"; | |
| 3 | +import { | |
| 4 | + buildMobilePluginCommandItems, | |
| 5 | + reconcileComposerSelectionForTextChange, | |
| 6 | +} from "./plugin-command-menu"; | |
| 4 | 7 | |
| 5 | 8 | describe("buildMobilePluginCommandItems", () => { |
| 6 | 9 | it("renders only matching mobile command contributions", () => { |
| @@ -25,3 +28,12 @@ describe("buildMobilePluginCommandItems", () => { · 18 unmodified lines | ||
| 25 | 28 | expect(items[0]?.type).toBe("plugin-command"); |
| 26 | 29 | }); |
| 27 | 30 | }); |
| 31 | + | |
| 32 | +describe("reconcileComposerSelectionForTextChange", () => { | |
| 33 | + it("moves an end-positioned caret after prompt hydration", () => { | |
| 34 | + expect(reconcileComposerSelectionForTextChange({ start: 0, end: 0 }, 0, 14)).toEqual({ | |
| 35 | + start: 14, | |
| 36 | + end: 14, | |
| 37 | + }); | |
| 38 | + }); | |
| 39 | +}); | |
| @@ -2,6 +2,20 @@ import type { PluginCommand } from "@t3tools/contracts"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { ComposerCommandItem } from "./ComposerCommandPopover"; |
| 4 | 4 | |
| 5 | +export function reconcileComposerSelectionForTextChange( | |
| 6 | + selection: { readonly start: number; readonly end: number }, | |
| 7 | + previousLength: number, | |
| 8 | + nextLength: number, | |
| 9 | +): { readonly start: number; readonly end: number } { | |
| 10 | + if (selection.start === previousLength && selection.end === previousLength) { | |
| 11 | + return { start: nextLength, end: nextLength }; | |
| 12 | + } | |
| 13 | + return { | |
| 14 | + start: Math.min(selection.start, nextLength), | |
| 15 | + end: Math.min(selection.end, nextLength), | |
| 16 | + }; | |
| 17 | +} | |
| 18 | + | |
| 5 | 19 | export function buildMobilePluginCommandItems( |
| 6 | 20 | commands: ReadonlyArray<PluginCommand>, |
| 7 | 21 | query: string, |
| @@ -8,7 +8,7 @@ cordis is not a dependency. a pure-only executor was rejected because plugin lif · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | ## contributions |
| 10 | 10 | |
| 11 | −plugins register detached declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. | |
| 11 | +plugins register detached, deeply frozen, json-compatible declarative metadata and an optional host-only live value. snapshots and `contributions(slot)` expose only frozen metadata. executable values never cross the rpc boundary. | |
| 12 | 12 | |
| 13 | 13 | `contributions(slot)` returns the committed generation with its entries. hosts must pass that generation to `useContribution(...)`; stale callers fail instead of invoking a handler from a different composition. invocation, reconciliation, and shutdown share the same transition authority, so a plugin scope cannot retire while its contribution is running. |
| 14 | 14 | |
| @@ -1,4 +1,12 @@ | ||
| 1 | −export interface Contribution<Data = unknown> { | |
| 1 | +export type ContributionData = | |
| 2 | + | null | |
| 3 | + | boolean | |
| 4 | + | number | |
| 5 | + | string | |
| 6 | + | ReadonlyArray<ContributionData> | |
| 7 | + | { readonly [key: string]: ContributionData }; | |
| 8 | + | |
| 9 | +export interface Contribution<Data extends ContributionData = ContributionData> { | |
| 2 | 10 | readonly id: string; |
| 3 | 11 | readonly label: string; |
| 4 | 12 | readonly data?: Data; |
| @@ -12,6 +12,7 @@ import * as Scope from "effect/Scope"; · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import type { |
| 14 | 14 | Contribution, |
| 15 | + ContributionData, | |
| 15 | 16 | PluginActivationContext, |
| 16 | 17 | PluginDefinition, |
| 17 | 18 | PluginRuntimeContributionSnapshot, |
| @@ -245,19 +246,58 @@ const sameStringRecord = ( · 227 unmodified lines | ||
| 245 | 246 | return leftKeys.every((key) => left[key] === right[key]); |
| 246 | 247 | }; |
| 247 | 248 | |
| 248 | −const deepFreeze = <Value>(value: Value): Value => { | |
| 249 | − if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; | |
| 250 | − for (const nested of Object.values(value)) deepFreeze(nested); | |
| 251 | − return Object.freeze(value); | |
| 249 | +const cloneContributionData = ( | |
| 250 | + value: ContributionData, | |
| 251 | + ancestors = new WeakSet<object>(), | |
| 252 | +): ContributionData => { | |
| 253 | + if (value === null || typeof value === "string" || typeof value === "boolean") return value; | |
| 254 | + if (typeof value === "number") { | |
| 255 | + if (!Number.isFinite(value)) throw new TypeError("Contribution data numbers must be finite"); | |
| 256 | + return value; | |
| 257 | + } | |
| 258 | + if (typeof value !== "object") { | |
| 259 | + throw new TypeError("Contribution data must contain only JSON-compatible values"); | |
| 260 | + } | |
| 261 | + if (ancestors.has(value)) throw new TypeError("Contribution data cannot contain cycles"); | |
| 262 | + ancestors.add(value); | |
| 263 | + try { | |
| 264 | + if (Array.isArray(value)) { | |
| 265 | + const clone: Array<ContributionData> = []; | |
| 266 | + for (let index = 0; index < value.length; index += 1) { | |
| 267 | + if (!Object.hasOwn(value, index)) { | |
| 268 | + throw new TypeError("Contribution data arrays cannot contain holes"); | |
| 269 | + } | |
| 270 | + clone.push(cloneContributionData(value[index]!, ancestors)); | |
| 271 | + } | |
| 272 | + return Object.freeze(clone); | |
| 273 | + } | |
| 274 | + | |
| 275 | + const prototype = Object.getPrototypeOf(value); | |
| 276 | + if (prototype !== Object.prototype && prototype !== null) { | |
| 277 | + throw new TypeError("Contribution data objects must use a plain or null prototype"); | |
| 278 | + } | |
| 279 | + const clone = createNullPrototypeRecord<ContributionData>(); | |
| 280 | + for (const key of Reflect.ownKeys(value)) { | |
| 281 | + if (typeof key !== "string") { | |
| 282 | + throw new TypeError("Contribution data objects cannot use symbol keys"); | |
| 283 | + } | |
| 284 | + const descriptor = Object.getOwnPropertyDescriptor(value, key); | |
| 285 | + if (descriptor === undefined || descriptor.enumerable !== true || !("value" in descriptor)) { | |
| 286 | + throw new TypeError("Contribution data objects must use enumerable data properties"); | |
| 287 | + } | |
| 288 | + clone[key] = cloneContributionData(descriptor.value as ContributionData, ancestors); | |
| 289 | + } | |
| 290 | + return Object.freeze(clone); | |
| 291 | + } finally { | |
| 292 | + ancestors.delete(value); | |
| 293 | + } | |
| 252 | 294 | }; |
| 253 | 295 | |
| 254 | 296 | const detachContribution = (contribution: Contribution): Contribution => |
| 255 | 297 | Object.freeze({ |
| 256 | 298 | id: contribution.id, |
| 257 | 299 | label: contribution.label, |
| 258 | − ...(contribution.data === undefined | |
| 259 | − ? {} | |
| 260 | − : { data: deepFreeze(structuredClone(contribution.data)) }), | |
| 300 | + ...(contribution.data === undefined ? {} : { data: cloneContributionData(contribution.data) }), | |
| 261 | 301 | }); |
| 262 | 302 | |
| 263 | 303 | const snapshotDefinitions = ( |
| @@ -347,6 +347,51 @@ describe("plugin runtime live contributions", () => { · 346 unmodified lines | ||
| 347 | 347 | }), |
| 348 | 348 | ); |
| 349 | 349 | |
| 350 | + it.effect("rejects non-declarative contribution data without replacing the composition", () => | |
| 351 | + Effect.gen(function* () { | |
| 352 | + const runtime = yield* PluginRuntime.make(); | |
| 353 | + const stable: PluginDefinition = { | |
| 354 | + id: "acme.commands", | |
| 355 | + version: "1.0.0", | |
| 356 | + activate(context) { | |
| 357 | + context.register("commands", { id: "status", label: "Status" }, Effect.void); | |
| 358 | + }, | |
| 359 | + }; | |
| 360 | + yield* runtime.reconcile([stable]); | |
| 361 | + const committed = yield* runtime.contributions("commands"); | |
| 362 | + const circular: { self?: unknown } = {}; | |
| 363 | + circular.self = circular; | |
| 364 | + const invalidValues: ReadonlyArray<unknown> = [ | |
| 365 | + circular, | |
| 366 | + new Map([["status", true]]), | |
| 367 | + new Uint8Array([1]), | |
| 368 | + ]; | |
| 369 | + | |
| 370 | + for (const [index, data] of invalidValues.entries()) { | |
| 371 | + const failed = yield* Effect.exit( | |
| 372 | + runtime.reconcile([ | |
| 373 | + { | |
| 374 | + id: "acme.commands", | |
| 375 | + version: `2.0.${index}`, | |
| 376 | + activate(context) { | |
| 377 | + context.register("commands", { | |
| 378 | + id: "status", | |
| 379 | + label: "Status", | |
| 380 | + data: data as never, | |
| 381 | + }); | |
| 382 | + }, | |
| 383 | + }, | |
| 384 | + ]), | |
| 385 | + ); | |
| 386 | + expect(Exit.isFailure(failed)).toBe(true); | |
| 387 | + if (Exit.isFailure(failed)) { | |
| 388 | + expect(Cause.squash(failed.cause)).toMatchObject({ _tag: "PluginCallbackError" }); | |
| 389 | + } | |
| 390 | + expect((yield* runtime.contributions("commands")).generation).toBe(committed.generation); | |
| 391 | + } | |
| 392 | + }), | |
| 393 | + ); | |
| 394 | + | |
| 350 | 395 | it.effect("uses detached metadata as the default live contribution value", () => |
| 351 | 396 | Effect.gen(function* () { |
| 352 | 397 | const runtime = yield* PluginRuntime.make(); |
| @@ -26,6 +26,7 @@ const workspaceFiles = [ · 25 unmodified lines | ||
| 26 | 26 | "oxlint-plugin-t3code/package.json", |
| 27 | 27 | "packages/client-runtime/package.json", |
| 28 | 28 | "packages/contracts/package.json", |
| 29 | + "packages/plugin-runtime/package.json", | |
| 29 | 30 | "packages/shared/package.json", |
| 30 | 31 | "packages/ssh/package.json", |
| 31 | 32 | "packages/tailscale/package.json", |