Unified · Split
| @@ -72,7 +72,10 @@ import { · 71 unmodified lines | |||
| 72 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; | 72 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 73 | import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; | 73 | import { detectComposerTrigger, replaceTextRange } from "@t3tools/shared/composerTrigger"; |
| 74 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 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 | import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; | 79 | import { useMobilePluginCommands } from "./use-mobile-plugin-commands"; |
| 77 | 80 | ||
| 78 | function NewTaskWorkspaceIcon(props: { | 81 | function NewTaskWorkspaceIcon(props: { |
| @@ -138,6 +141,7 @@ export function NewTaskDraftScreen(props: { · 59 unmodified lines | |||
| 138 | start: flow.prompt.length, | 141 | start: flow.prompt.length, |
| 139 | end: flow.prompt.length, | 142 | end: flow.prompt.length, |
| 140 | })); | 143 | })); |
| 144 | const previousPromptLengthRef = useRef(flow.prompt.length); | ||
| 141 | const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( | 145 | const { commands: pluginCommands, execute: executePluginCommand } = useMobilePluginCommands( |
| 142 | selectedProject?.environmentId ?? null, | 146 | selectedProject?.environmentId ?? null, |
| 143 | ); | 147 | ); |
| @@ -169,13 +173,11 @@ export function NewTaskDraftScreen(props: { · 25 unmodified lines | |||
| 169 | ); | 173 | ); |
| 170 | useEffect(() => { | 174 | useEffect(() => { |
| 171 | const end = flow.prompt.length; | 175 | const end = flow.prompt.length; |
| 172 | setComposerSelection((selection) => { | 176 | const previousEnd = previousPromptLengthRef.current; |
| 173 | const start = Math.min(selection.start, end); | 177 | previousPromptLengthRef.current = end; |
| 174 | const selectionEnd = Math.min(selection.end, end); | 178 | setComposerSelection((selection) => |
| 175 | return start === selection.start && selectionEnd === selection.end | 179 | reconcileComposerSelectionForTextChange(selection, previousEnd, end), |
| 176 | ? selection | 180 | ); |
| 177 | : { start, end: selectionEnd }; | ||
| 178 | }); | ||
| 179 | }, [flow.prompt.length]); | 181 | }, [flow.prompt.length]); |
| 180 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | 182 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ |
| 181 | editorRef: promptInputRef, | 183 | editorRef: promptInputRef, |
| @@ -1,6 +1,9 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("buildMobilePluginCommandItems", () => { | 8 | describe("buildMobilePluginCommandItems", () => { |
| 6 | it("renders only matching mobile command contributions", () => { | 9 | it("renders only matching mobile command contributions", () => { |
| @@ -25,3 +28,12 @@ describe("buildMobilePluginCommandItems", () => { · 18 unmodified lines | |||
| 25 | expect(items[0]?.type).toBe("plugin-command"); | 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 | import type { ComposerCommandItem } from "./ComposerCommandPopover"; | 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 | export function buildMobilePluginCommandItems( | 19 | export function buildMobilePluginCommandItems( |
| 6 | commands: ReadonlyArray<PluginCommand>, | 20 | commands: ReadonlyArray<PluginCommand>, |
| 7 | query: string, | 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 | ## contributions | 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 | `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. | 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 | readonly id: string; | 10 | readonly id: string; |
| 3 | readonly label: string; | 11 | readonly label: string; |
| 4 | readonly data?: Data; | 12 | readonly data?: Data; |
| @@ -12,6 +12,7 @@ import * as Scope from "effect/Scope"; · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import type { | 13 | import type { |
| 14 | Contribution, | 14 | Contribution, |
| 15 | ContributionData, | ||
| 15 | PluginActivationContext, | 16 | PluginActivationContext, |
| 16 | PluginDefinition, | 17 | PluginDefinition, |
| 17 | PluginRuntimeContributionSnapshot, | 18 | PluginRuntimeContributionSnapshot, |
| @@ -245,19 +246,58 @@ const sameStringRecord = ( · 227 unmodified lines | |||
| 245 | return leftKeys.every((key) => left[key] === right[key]); | 246 | return leftKeys.every((key) => left[key] === right[key]); |
| 246 | }; | 247 | }; |
| 247 | 248 | ||
| 248 | const deepFreeze = <Value>(value: Value): Value => { | 249 | const cloneContributionData = ( |
| 249 | if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; | 250 | value: ContributionData, |
| 250 | for (const nested of Object.values(value)) deepFreeze(nested); | 251 | ancestors = new WeakSet<object>(), |
| 251 | return Object.freeze(value); | 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 | const detachContribution = (contribution: Contribution): Contribution => | 296 | const detachContribution = (contribution: Contribution): Contribution => |
| 255 | Object.freeze({ | 297 | Object.freeze({ |
| 256 | id: contribution.id, | 298 | id: contribution.id, |
| 257 | label: contribution.label, | 299 | label: contribution.label, |
| 258 | ...(contribution.data === undefined | 300 | ...(contribution.data === undefined ? {} : { data: cloneContributionData(contribution.data) }), |
| 259 | ? {} | ||
| 260 | : { data: deepFreeze(structuredClone(contribution.data)) }), | ||
| 261 | }); | 301 | }); |
| 262 | 302 | ||
| 263 | const snapshotDefinitions = ( | 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 | it.effect("uses detached metadata as the default live contribution value", () => | 395 | it.effect("uses detached metadata as the default live contribution value", () => |
| 351 | Effect.gen(function* () { | 396 | Effect.gen(function* () { |
| 352 | const runtime = yield* PluginRuntime.make(); | 397 | const runtime = yield* PluginRuntime.make(); |
| @@ -26,6 +26,7 @@ const workspaceFiles = [ · 25 unmodified lines | |||
| 26 | "oxlint-plugin-t3code/package.json", | 26 | "oxlint-plugin-t3code/package.json", |
| 27 | "packages/client-runtime/package.json", | 27 | "packages/client-runtime/package.json", |
| 28 | "packages/contracts/package.json", | 28 | "packages/contracts/package.json", |
| 29 | "packages/plugin-runtime/package.json", | ||
| 29 | "packages/shared/package.json", | 30 | "packages/shared/package.json", |
| 30 | "packages/ssh/package.json", | 31 | "packages/ssh/package.json", |
| 31 | "packages/tailscale/package.json", | 32 | "packages/tailscale/package.json", |