diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index dd645a3379f6614fdb70bafd3b4e495abd286a57..37d68ec05ddec3fd3566b9e3f5ff2337522e090b 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; export interface DesktopIpcInvokeEvent { readonly sender: { readonly id: number }; + readonly senderFrame?: { readonly url: string; readonly parent: unknown } | null; } export interface DesktopIpcSyncEvent { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index caed223e90e8666176c1f76e59f835a567a63531..585bbb3864b0fbe8ddbca903baefdc6740976ccf 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,3 +1,4 @@ +import { openVideo } from "./methods/media.ts"; import * as Effect from "effect/Effect"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; @@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(openVideo); yield* ipc.handle(receiveProviderAuthCallback); yield* ipc.handle(cancelProviderAuthCallback); yield* ipc.handle(openSystemSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 8e2ebec04f95883050fa53341abdad0281d1582a..9a8e05d20ee25dda9a209ddcb9f574354a6117b4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -124,3 +124,5 @@ export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-au export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal"; + +export const OPEN_VIDEO_CHANNEL = "desktop:open-video"; diff --git a/apps/desktop/src/ipc/methods/media.test.ts b/apps/desktop/src/ipc/methods/media.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c316641468abba0141fb4a5b9793c88508482a64 --- /dev/null +++ b/apps/desktop/src/ipc/methods/media.test.ts @@ -0,0 +1,56 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Electron from "electron"; +import { vi } from "vite-plus/test"; + +vi.mock("electron", () => ({})); + +import * as DesktopConfig from "../../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopVideo from "../../media/DesktopVideo.ts"; +import { getDesktopUrl } from "../../electron/ElectronProtocol.ts"; +import { openVideo } from "./media.ts"; + +it.effect("allows only the top-level app frame to open a video", () => { + const calls: unknown[] = []; + // Electron owns this object; only the native identity checks are exercised here. + const window = { isDestroyed: () => false, webContents: { id: 42 } } as Electron.BrowserWindow; + const layer = Layer.mergeAll( + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/tmp/t3-media-test", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.45", + appPath: "/tmp/T3 Code.app", + isPackaged: true, + resourcesPath: "/tmp/resources", + runningUnderArm64Translation: false, + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))), + Layer.mock(ElectronWindow.ElectronWindow, { main: Effect.succeedSome(window) }), + Layer.succeed(DesktopVideo.DesktopVideo, { + open: (input) => + Effect.sync(() => { + calls.push(input); + }), + }), + ); + const input = { url: "https://remote.test/clip.mp4", name: "clip.mp4" }; + const trustedFrame = { parent: null, url: getDesktopUrl(false) }; + return Effect.gen(function* () { + for (const event of [ + undefined, + { sender: { id: 100 }, senderFrame: trustedFrame }, + { sender: { id: 42 }, senderFrame: { parent: {}, url: trustedFrame.url } }, + { sender: { id: 42 }, senderFrame: { parent: null, url: "https://preview.test" } }, + { sender: { id: 42 } }, + ]) + assert.equal((yield* Effect.result(openVideo.handler(input, event)))._tag, "Failure"); + assert.equal(calls.length, 0); + yield* openVideo.handler(input, { sender: { id: 42 }, senderFrame: trustedFrame }); + assert.deepEqual(calls, [input]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/desktop/src/ipc/methods/media.ts b/apps/desktop/src/ipc/methods/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..c98502645f4ac6d995668312a30d7619daa07a15 --- /dev/null +++ b/apps/desktop/src/ipc/methods/media.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { getDesktopUrl } from "../../electron/ElectronProtocol.ts"; +import * as DesktopVideo from "../../media/DesktopVideo.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +export const openVideo = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_VIDEO_CHANNEL, + payload: Schema.Struct({ + url: Schema.String.check(Schema.isMaxLength(16_384)), + name: Schema.String.check(Schema.isMaxLength(255)), + }), + result: Schema.Void, + handler: Effect.fn("desktop.ipc.media.openVideo")(function* (input, event) { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const main = yield* (yield* ElectronWindow.ElectronWindow).main; + const expected = new URL(getDesktopUrl(environment.isDevelopment)); + const frame = event?.senderFrame; + const source = frame && URL.canParse(frame.url) ? new URL(frame.url) : null; + if ( + event === undefined || + Option.isNone(main) || + main.value.isDestroyed() || + main.value.webContents.id !== event.sender.id || + frame?.parent !== null || + source?.protocol !== expected.protocol || + source.host !== expected.host + ) + return yield* new DesktopVideo.DesktopVideoError({ stage: "source" }); + + return yield* (yield* DesktopVideo.DesktopVideo).open(input); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5720858f6993a31beae743e5c4dd9acfa1dfd99f..9c0b083f00fc28a6d911ab780a140e377f46920d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import * as DesktopVideo from "./media/DesktopVideo.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; @@ -216,6 +217,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( const layerDesktopApplication = Layer.mergeAll( DesktopLifecycle.layer, + DesktopVideo.layer, layerDesktopAppActivation, DesktopWebLinks.layer, DesktopApplicationMenu.layer, diff --git a/apps/desktop/src/media/DesktopVideo.test.ts b/apps/desktop/src/media/DesktopVideo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..896f79edcb90179be7d75077f13a4a0339578eb8 --- /dev/null +++ b/apps/desktop/src/media/DesktopVideo.test.ts @@ -0,0 +1,167 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { beforeEach, vi } from "vite-plus/test"; + +const { fetchVideo, open } = vi.hoisted(() => ({ fetchVideo: vi.fn(), open: vi.fn() })); +vi.mock("electron", () => ({ net: { fetch: fetchVideo } })); + +import * as DesktopVideo from "./DesktopVideo.ts"; + +const spawner = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { + exitCode: (command) => + Effect.sync(() => { + open(command); + return ChildProcessSpawner.ExitCode(0); + }), +}); + +beforeEach(() => { + fetchVideo.mockReset().mockImplementation( + async () => + new Response("video bytes", { + headers: { "content-type": "video/mp4" }, + }), + ); + open.mockReset(); +}); + +const layer = DesktopVideo.layer.pipe(Layer.provide(spawner)); + +it.effect( + "streams to a private client temp file, opens only QuickTime, and cleans up on app scope exit", + () => { + let openedPath = ""; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.gen(function* () { + const video = yield* DesktopVideo.DesktopVideo; + yield* video.open({ + url: "https://remote.test/api/assets/signed/clip.mp4", + name: "../../clip.mp4", + }); + const command = open.mock.lastCall![0]; + assert.equal(command.command, "/usr/bin/open"); + assert.deepEqual(command.args.slice(0, 2), [ + "-a", + "/System/Applications/QuickTime Player.app", + ]); + openedPath = command.args[2]; + assert.match(openedPath, /\/t3-video-[^/]+\/clip\.mp4$/); + assert.equal(yield* fs.readFileString(openedPath), "video bytes"); + assert.equal((yield* fs.stat(openedPath)).mode & 0o777, 0o600); + }).pipe(Effect.provide(layer)); + assert.isFalse(yield* fs.exists(openedPath)); + }).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide(NodeServices.layer), + ); + }, +); + +it.effect("rejects non-macOS before downloading", () => + Effect.gen(function* () { + const result = yield* Effect.result( + (yield* DesktopVideo.DesktopVideo).open({ + url: "https://remote.test/clip.mp4", + name: "clip.mp4", + }), + ); + assert.equal(result._tag, "Failure"); + assert.equal(fetchVideo.mock.calls.length, 0); + assert.equal(open.mock.calls.length, 0); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provide(NodeServices.layer), + ), +); + +it.effect("rejects file URLs and nonvideo filenames without reading local files", () => + Effect.gen(function* () { + const video = yield* DesktopVideo.DesktopVideo; + for (const input of [ + { url: "file:///Users/private/clip.mp4", name: "clip.mp4" }, + { url: "https://remote.test/run", name: "run.command" }, + { url: "https://user:password@remote.test/clip.mp4", name: "clip.mp4" }, + ]) + assert.equal((yield* Effect.result(video.open(input)))._tag, "Failure"); + assert.equal(fetchVideo.mock.calls.length, 0); + assert.equal(open.mock.calls.length, 0); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide(NodeServices.layer), + ), +); + +it.effect("does not open an authorization error or HTML error page as a video", () => + Effect.gen(function* () { + const video = yield* DesktopVideo.DesktopVideo; + for (const response of [ + new Response("unauthorized", { status: 401 }), + new Response("sign in", { headers: { "content-type": "text/html" } }), + ]) { + fetchVideo.mockResolvedValueOnce(response); + assert.equal( + (yield* Effect.result( + video.open({ url: "https://remote.test/clip.mp4", name: "clip.mp4" }), + ))._tag, + "Failure", + ); + } + assert.equal(open.mock.calls.length, 0); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide(NodeServices.layer), + ), +); + +it.effect("does not launch a partial download when its stream fails", () => + Effect.gen(function* () { + fetchVideo.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + controller.error(new Error("disconnected")); + }, + }), + { headers: { "content-type": "video/mp4" } }, + ), + ); + const video = yield* DesktopVideo.DesktopVideo; + assert.equal( + (yield* Effect.result(video.open({ url: "https://remote.test/clip.mp4", name: "clip.mp4" }))) + ._tag, + "Failure", + ); + assert.equal(open.mock.calls.length, 0); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide(NodeServices.layer), + ), +); + +it.effect("uses the video response type for an extensionless remote download", () => + Effect.gen(function* () { + fetchVideo.mockResolvedValueOnce( + new Response("movie", { headers: { "content-type": "video/quicktime" } }), + ); + yield* (yield* DesktopVideo.DesktopVideo).open({ + url: "https://remote.test/download?id=clip", + name: "recording", + }); + assert.match(open.mock.lastCall![0].args[2], /\/recording\.mov$/); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/desktop/src/media/DesktopVideo.ts b/apps/desktop/src/media/DesktopVideo.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c0f090a6177eed0f11104055ea1c0dd6a5e2985 --- /dev/null +++ b/apps/desktop/src/media/DesktopVideo.ts @@ -0,0 +1,122 @@ +import { VIDEO_FILE_EXTENSIONS, videoMimeType } from "@t3tools/shared/video"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Electron from "electron"; + +export class DesktopVideoError extends Schema.TaggedError()( + "DesktopVideoError", + { + stage: Schema.Literals(["unsupported", "source", "download", "open"]), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message() { + switch (this.stage) { + case "unsupported": + return "QuickTime is available only on macOS."; + case "source": + return "This video cannot be opened in QuickTime. Try downloading it."; + case "download": + return "The video could not be downloaded. Reconnect and try again."; + case "open": + return "QuickTime could not open the video. Try downloading it."; + } + } +} + +export class DesktopVideo extends Context.Service< + DesktopVideo, + { + readonly open: (input: { + readonly url: string; + readonly name: string; + }) => Effect.Effect; + } +>()("@t3tools/desktop/media/DesktopVideo") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Effect.scope; + + return DesktopVideo.of({ + open: Effect.fn("DesktopVideo.open")(function* (input) { + if (platform !== "darwin") return yield* new DesktopVideoError({ stage: "unsupported" }); + const url = yield* Effect.try({ + try: () => new URL(input.url), + catch: (cause) => new DesktopVideoError({ stage: "source", cause }), + }); + const name = Array.from(path.basename(input.name.replaceAll("\\", "/"))) + .filter((character) => character.charCodeAt(0) >= 32 && character !== "\x7f") + .join(""); + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password || + !name || + name.length > 255 || + (path.extname(name) !== "" && !videoMimeType({ name, mimeType: "" })) + ) + return yield* new DesktopVideoError({ stage: "source" }); + + const response = yield* Effect.tryPromise({ + try: (signal) => Electron.net.fetch(url.href, { signal }), + catch: (cause) => new DesktopVideoError({ stage: "download", cause }), + }); + const body = response.body; + const mimeType = response.headers.get("content-type") ?? ""; + if (!response.ok || !body || !videoMimeType({ name, mimeType })) { + yield* Effect.promise(() => body?.cancel().catch(() => undefined) ?? Promise.resolve()); + return yield* new DesktopVideoError({ stage: "download" }); + } + + const extension = + path.extname(name) || + `.${ + VIDEO_FILE_EXTENSIONS.find( + (extension) => + videoMimeType({ name: `video.${extension}`, mimeType: "" }) === + mimeType.split(";", 1)[0]?.trim().toLowerCase(), + ) ?? "mp4" + }`; + const fileName = path.extname(name) ? name : `${name.slice(0, 240)}${extension}`; + // QuickTime keeps reading after this action returns. + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-" }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })), + ); + const file = path.join(directory, fileName); + yield* Stream.fromReadableStream({ + evaluate: () => body, + onError: (cause) => new DesktopVideoError({ stage: "download", cause }), + }).pipe( + Stream.run(fs.sink(file, { flag: "wx", mode: 0o600 })), + Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })), + Effect.onError(() => fs.remove(file, { force: true }).pipe(Effect.ignore)), + ); + const exitCode = yield* spawner + .exitCode( + ChildProcess.make( + "/usr/bin/open", + ["-a", "/System/Applications/QuickTime Player.app", file], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ), + ) + .pipe(Effect.mapError((cause) => new DesktopVideoError({ stage: "open", cause }))); + if (exitCode !== 0) return yield* new DesktopVideoError({ stage: "open" }); + }), + }); +}); + +export const layer = Layer.effect(DesktopVideo, make); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c4e3d10e54d73724241177f688b7d088f10e81b0..0278a55944a6b20aa5a99b88aa34787a891f6c50 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -226,6 +226,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { cancelProviderAuthCallback: (url: string) => ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + ...(clientPlatform === "darwin" + ? { + openVideo: (input: { url: string; name: string }) => + ipcRenderer.invoke(IpcChannels.OPEN_VIDEO_CHANNEL, input), + } + : {}), checkSystemPermission: (pane: string) => ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), openSystemSettings: (pane: string) => diff --git a/apps/web/package.json b/apps/web/package.json index f893f771ba324af925690a75c19095d8acd80ee8..a2e532a200927b0fbfdc6ec0453b94d28d454751 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,13 @@ "@base-ui/react": "^1.4.1", "@clerk/electron": "catalog:", "@clerk/react": "catalog:", + "@clo/markdown-editor": "workspace:*", "@clo/terminal": "workspace:*", + "@codemirror/commands": "^6.8.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@daypicker/react": "^10.0.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -23,6 +29,7 @@ "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", + "@lezer/common": "^1.5.2", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.6", @@ -38,22 +45,11 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", - "@tiptap/core": "^3.31.3", - "@tiptap/extension-blockquote": "^3.31.3", - "@tiptap/extension-code": "^3.31.3", - "@tiptap/extension-code-block": "^3.31.3", - "@tiptap/extension-heading": "^3.31.3", - "@tiptap/extension-horizontal-rule": "^3.31.3", - "@tiptap/extension-list": "^3.31.3", - "@tiptap/extension-task-item": "^3.31.3", - "@tiptap/extension-task-list": "^3.31.3", - "@tiptap/pm": "^3.31.3", - "@tiptap/react": "^3.31.3", - "@tiptap/starter-kit": "^3.31.3", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "dompurify": "^3.4.16", "effect": "catalog:", + "gemoji": "^8.1.0", "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index b842ee5fd1cf512c33e827055110defea48d8564..4a18de524466b714c672675221f4490a282bef04 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -103,6 +103,7 @@ export function applyAppearanceFontVariables( const families: ReadonlyArray = [ ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], + ["--chat-code-font", preferences.code, "var(--font-mono)"], // The composer falls back to whatever the sans preference resolves to. ["--font-composer", preferences.composer, "var(--font-sans)"], ]; diff --git a/apps/web/src/assets/fonts/Recursive-OFL.txt b/apps/web/src/assets/fonts/Recursive-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..d4361a99fe5b5091c2a7596ee55818da51373a3d --- /dev/null +++ b/apps/web/src/assets/fonts/Recursive-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Recursive Project Authors (https://github.com/arrowtype/recursive) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/assets/fonts/Recursive_VF_1.085.woff2 b/apps/web/src/assets/fonts/Recursive_VF_1.085.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0f4358fe576a4d634f536393a19beb2d2b6049ae Binary files /dev/null and b/apps/web/src/assets/fonts/Recursive_VF_1.085.woff2 differ diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index ca333f46a083c1b6320b578d51a2ded0d993a813..55c11d2644dd87f3f67e3ebda822bcb7ef323570 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -19,6 +19,9 @@ import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: () => ({ data: null, error: null }), +})); vi.mock("./chat/MermaidDiagram", () => ({ // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. MermaidDiagram: () => , @@ -95,6 +98,286 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown inline rendering", () => { + it.each([true, false])( + "keeps authored file-link labels as one actionable chip with parseRawHtml=%s", + async (parseRawHtml) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const text = + "[relative](./foo.md), [**formatted** `source`](./src/main.ts), [ref][file] and [foo.md](./foo.md)\n\n[file]: ./bar.md"; + try { + await act(() => + root.render( + , + ), + ); + const chips = [ + ...container.querySelectorAll("a.chat-markdown-file-link"), + ]; + expect(chips.map((chip) => chip.textContent)).toEqual([ + "relative", + "formatted source", + "ref", + "foo.md", + ]); + expect(container.querySelector("p")!.textContent).toBe( + "relative, formatted source, ref and foo.md", + ); + expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([ + "/workspace/project/./foo.md", + "/workspace/project/./src/main.ts", + "/workspace/project/./bar.md", + "/workspace/project/./foo.md", + ]); + expect(chips[1]!.querySelector("strong")!.textContent).toBe("formatted"); + expect(chips[1]!.querySelector("code")!.textContent).toBe("source"); + chips[0]!.focus(); + expect(document.activeElement).toBe(chips[0]); + expect(chips[0]!.getAttribute("aria-description")).toBe("/workspace/project/./foo.md"); + } finally { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }, + ); +}); + +describe("ChatMarkdown consecutive videos", () => { + it.each([ + [ + "separate paragraphs", + "![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)", + ], + [ + "one paragraph", + "![First](https://media.test/first.mp4) ![Second](https://media.test/second.webm)", + ], + [ + "hard breaks", + "![First](https://media.test/first.mp4) \n![Second](https://media.test/second.webm)", + ], + [ + "reference images", + "![First][first]\n\n![Second][second]\n\n[first]: https://media.test/first.mp4\n[second]: https://media.test/second.webm", + ], + [ + "sanitized HTML images", + 'First\n\nSecond', + ], + ])("groups %s without changing playback sources or accessible names", async (_case, text) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(() => root.render()); + const gallery = container.querySelector(".chat-markdown-video-row"); + expect(gallery).not.toBeNull(); + const videos = [...gallery!.querySelectorAll("video")]; + expect(videos.map((video) => video.src)).toEqual([ + "https://media.test/first.mp4", + "https://media.test/second.webm", + ]); + expect(videos.map((video) => video.getAttribute("aria-label"))).toEqual(["First", "Second"]); + expect(videos.every((video) => !video.controls && video.playsInline)).toBe(true); + expect(gallery!.querySelectorAll('button[aria-label="Play video"]')).toHaveLength(2); + if (_case === "sanitized HTML images") { + expect(videos[0]!.style.aspectRatio).toBe("640 / 480"); + } + } finally { + await act(() => root.unmount()); + pause.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("keeps prose, links, and images separate from grouped and lone videos", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + const text = [ + "![First](https://media.test/first.mp4)", + "![Second](https://media.test/second.webm)", + "Between videos.", + "![Third](https://media.test/third.mp4)", + "[Source](https://media.test/source)", + "![Still](https://media.test/still.png)", + "Caption ![Inline](https://media.test/inline.mp4)", + "![Last](https://media.test/last.mp4)", + ].join("\n\n"); + try { + await act(() => root.render()); + expect( + [...container.querySelectorAll(".chat-markdown-video-row")].map((row) => + row.getAttribute("data-video-count"), + ), + ).toEqual(["2", "1", "1"]); + expect( + container.querySelector(".chat-markdown-video-row")!.querySelectorAll("video"), + ).toHaveLength(2); + const paragraphs = [...container.querySelectorAll("p")]; + expect(paragraphs.map((paragraph) => paragraph.textContent)).toContain("Between videos."); + expect(container.querySelector('video[aria-label="Third"]')?.getAttribute("src")).toBe( + "https://media.test/third.mp4", + ); + expect(container.querySelector('video[aria-label="Last"]')?.getAttribute("src")).toBe( + "https://media.test/last.mp4", + ); + expect( + container + .querySelector('a[href="https://media.test/source"]')! + .closest(".chat-markdown-video-row"), + ).toBeNull(); + expect( + container.querySelector('img[alt="Still"]')!.closest(".chat-markdown-video-row"), + ).toBeNull(); + const caption = paragraphs.find((paragraph) => + paragraph.querySelector('[aria-label="Inline"]'), + )!; + const prose = [...caption.childNodes] + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent) + .join(""); + expect(prose).toBe("Caption "); + } finally { + await act(() => root.unmount()); + pause.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("keeps literal HTML mode and raw-video sanitization unchanged", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(() => + root.render( + ' + } + />, + ), + ); + expect(container.querySelectorAll("video")).toHaveLength(2); + expect(container.querySelectorAll(".chat-markdown-video-row")).toHaveLength(1); + expect(container.textContent).toContain( + '', + ); + await act(() => + root.render( + , + ), + ); + expect(container.querySelector("video")).toBeNull(); + } finally { + await act(() => root.unmount()); + pause.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("preserves authored paragraph anchors beside grouped videos", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(() => + root.render( + Anchored

\n\n![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)' + } + />, + ), + ); + const anchored = container.querySelector("#user-content-clip"); + expect(anchored?.tagName).toBe("P"); + expect(anchored?.querySelector("video")?.src).toBe("https://media.test/anchored.mp4"); + expect(anchored?.closest(".chat-markdown-video-row")).toBeNull(); + expect( + container.querySelector(".chat-markdown-video-row")?.querySelectorAll("video"), + ).toHaveLength(2); + } finally { + await act(() => root.unmount()); + pause.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it.each(["", "```text\ncompleted prefix\n```\n\n"])( + "preserves playing video nodes as a streamed singleton grows (prefix=%j)", + async (completedPrefix) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + const prefix = `${completedPrefix}![First](https://media.test/first.mp4)`; + try { + await act(() => root.render()); + const firstPlayer = container.querySelector("video"); + expect(firstPlayer).not.toBeNull(); + firstPlayer!.currentTime = 7; + await act(() => firstPlayer!.dispatchEvent(new Event("play"))); + await act(() => + root.render( + , + ), + ); + expect(container.querySelector("video")).toBe(firstPlayer); + expect(container.querySelector("video")?.currentTime).toBe(7); + expect( + container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"), + ).toBe("2"); + await act(() => + root.render( + , + ), + ); + expect( + container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"), + ).toBe("3"); + expect( + [...container.querySelectorAll("video")].map((video) => video.getAttribute("aria-label")), + ).toEqual(["First", "Second", "Third"]); + expect(container.querySelector("video")).toBe(firstPlayer); + expect(container.querySelector("video")?.currentTime).toBe(7); + } finally { + await act(() => root.unmount()); + pause.mockRestore(); + vi.unstubAllGlobals(); + } + }, + ); +}); + describe("ChatMarkdown bare anchor placeholders", () => { it.each(["", "", "", "", "", ""])( "preserves unmatched %s without linking later blocks", @@ -1014,7 +1297,7 @@ describe("ChatMarkdown Windows file links", () => { , diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd0343ca990ec556219320c98648b70cc1aea99a..835f08101c5297fa80d76ba2a0248f2b7ad807fe 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -107,7 +107,6 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { artifactTemplateFromHastProperties, renderCodexInlineDirectivesAsMarkdown, @@ -496,16 +495,6 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): ); } -type MarkdownAstNode = { - type?: string; - meta?: unknown; - url?: string; - data?: { - hProperties?: Record; - }; - children?: MarkdownAstNode[]; -}; - function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -1207,7 +1196,7 @@ interface MarkdownFileLinkProps { absolute host path outside it, null when the panel cannot show the file. */ panelPath: string | null; line?: number | undefined; - label: string; + label: ReactNode; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; @@ -1560,7 +1549,7 @@ function ChatMarkdownVideo(props: { className={cn( CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - "w-full", + "w-full max-w-[min(100%,30rem,calc(30rem*var(--media-video-ratio)))]", )} videoClassName={cn( CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, @@ -1718,8 +1707,8 @@ function leadingExternalLinkTextLength(text: string): number { function breakableExternalLinkText(text: string): ReactNode[] { return Array.from(text, (character, index) => ( + {index > 0 && } {character} - )); } @@ -1833,13 +1822,90 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent, findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); } -type HeadingHastNode = { +type MarkdownHastNode = { type?: string; + value?: string; tagName?: string; properties?: Record; - children?: HeadingHastNode[]; + children?: MarkdownHastNode[]; }; +function rehypeVideoRows() { + return (tree: MarkdownHastNode) => { + const isVideoImage = (node: MarkdownHastNode) => { + const src = node.properties?.dataLocalSrc ?? node.properties?.src; + return ( + node.type === "element" && + node.tagName === "img" && + typeof src === "string" && + mediaKindFromPath(src) === "video" + ); + }; + const videosInBlock = (node: MarkdownHastNode) => { + if (isVideoImage(node)) return [node]; + if ( + node.type !== "element" || + node.tagName !== "p" || + Object.keys(node.properties ?? {}).length > 0 + ) { + return null; + } + const videos = (node.children ?? []).filter( + (child) => + !(child.type === "text" && !child.value?.trim()) && + !(child.type === "element" && child.tagName === "br"), + ); + return videos.length > 0 && videos.every(isVideoImage) ? videos : null; + }; + const visit = (node: MarkdownHastNode) => { + if (!node.children) return; + node.children.forEach(visit); + if ( + node.type !== "root" && + !["div", "blockquote", "li", "td", "th", "figure", "details"].includes(node.tagName ?? "") + ) { + return; + } + + for (let index = 0; index < node.children.length; index++) { + const first = node.children[index]!; + const videos = videosInBlock(first); + if (!videos) continue; + let end = index + 1; + while (end < node.children.length) { + const next = node.children[end]!; + if (next.type === "text" && !next.value?.trim()) { + end++; + continue; + } + const moreVideos = videosInBlock(next); + if (!moreVideos) break; + videos.push(...moreVideos); + end++; + } + node.children.splice(index, end - index, { + type: "element", + tagName: "div", + properties: { + className: [ + "chat-markdown-video-row", + ...(first.tagName === "img" ? ["chat-markdown-video-bare"] : []), + ], + dataVideoCount: videos.length, + }, + children: videos.map((video) => ({ + type: "element", + tagName: "div", + properties: {}, + children: [video], + })), + }); + } + }; + visit(tree); + }; +} + /** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ function githubHeadingSlug(text: string): string { return text @@ -1855,18 +1921,18 @@ function githubHeadingSlug(text: string): string { * fragment lookup strips it. */ function rehypeHeadingIds() { - return (tree: HeadingHastNode) => { + return (tree: MarkdownHastNode) => { // Every id already in the document, authored or assigned, so a suffix never // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. const taken = new Set(); - const collect = (node: HeadingHastNode) => { + const collect = (node: MarkdownHastNode) => { const id = node.properties?.id; if (typeof id === "string") taken.add(id); node.children?.forEach(collect); }; collect(tree); const nextSuffix = new Map(); - const visit = (node: HeadingHastNode) => { + const visit = (node: MarkdownHastNode) => { if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { const slug = githubHeadingSlug(hastPlainTextDeep(node)); if (node.properties?.id === undefined && slug) { @@ -1892,11 +1958,13 @@ function rehypeHeadingIds() { const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, + rehypeVideoRows, ] satisfies NonNullable; -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ + rehypeHeadingIds, + rehypeVideoRows, +] satisfies NonNullable; function MarkdownExternalLinkContent({ host, @@ -2241,6 +2309,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ render={} className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} + aria-description={targetPath} onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -2267,7 +2336,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } - aria-label={`File options for ${label}`} + aria-label={`File options for ${nodeToPlainText(label)}`} + aria-description={targetPath} aria-haspopup="menu" className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} data-markdown-copy={copyMarkdown} @@ -2660,7 +2730,12 @@ function useChatMarkdownState({ [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( - (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { + ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + mediaSource?: string, + label?: ReactNode, + ) => { const mediaPath = mediaSource ?? fileLinkMeta.filePath; const canPreviewMedia = mediaMimeTypeFromExtension( @@ -2681,14 +2756,17 @@ function useChatMarkdownState({ displayPath={fileLinkMeta.displayPath} panelPath={panelPath} line={fileLinkMeta.line} - label={fileLinkLabel( - { - path: fileLinkMeta.filePath, - ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), - ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), - }, - fileLinkParentSuffixByPath, - )} + label={ + label ?? + fileLinkLabel( + { + path: fileLinkMeta.filePath, + ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), + ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), + }, + fileLinkParentSuffixByPath, + ) + } copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} @@ -2851,6 +2929,14 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } + const videoCount = node?.properties.dataVideoCount; + if (typeof videoCount === "number") { + return ( +
+ {children} +
+ ); + } return
{children}
; }, p: function MarkdownParagraph({ node: _node, children, ...props }) { @@ -3196,13 +3282,11 @@ const CHAT_MARKDOWN_COMPONENTS = { source.startsWith("[") && source.includes("](") ? source : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`; - const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref); - return isMarkdownFileLinkLabel(label, normalizedHref) ? ( - chip - ) : ( - - {children} {chip} - + return fileLinkChip( + fileLinkMeta, + copyMarkdown, + normalizedHref, + isMarkdownFileLinkLabel(label, normalizedHref) ? undefined : children, ); }, code: function MarkdownCode({ node, children, className, ...props }) { diff --git a/apps/web/src/components/ComposerPromptEditor.test.tsx b/apps/web/src/components/ComposerPromptEditor.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..366f11d8dcdd60efe7739955ee170ebb9954a5e2 --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditor.test.tsx @@ -0,0 +1,283 @@ +// @vitest-environment jsdom + +import { act, createRef, useLayoutEffect, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { collapseExpandedComposerCursor } from "~/composer-logic"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { ComposerContextActionsContext } from "./composerContextPresentation"; + +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; + +let root: Root; +let container: HTMLDivElement; +const handle = createRef(); +let replace: (value: string, cursor: number) => void; +let enableRichText: (enabled: boolean) => void; +const openMention = vi.fn(); +const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); +const changes: Array<{ value: string; suppressed: boolean }> = []; + +function ComposerProbe() { + const [richText, setRichText] = useState(false); + const [draft, setDraft] = useState({ + value: "before :dizzy after", + cursor: "before :dizzy".length, + }); + useLayoutEffect(() => { + replace = (value, cursor) => setDraft({ value, cursor }); + enableRichText = setRichText; + }); + return ( + {}, + expandVideo: () => {}, + openFile: () => {}, + openPullRequest: () => {}, + }} + > + {}} + onCommandKeyDown={commandKey} + onChange={(value, cursor, _expanded, suppressed) => { + changes.push({ value, suppressed }); + setDraft({ value, cursor }); + }} + /> + + ); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(), + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + changes.length = 0; + openMention.mockClear(); + commandKey.mockReset(); + commandKey.mockReturnValue(false); + await act(() => root.render()); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("emoji completion in the real composer editor", () => { + it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => { + await act(() => handle.current!.focus()); + expect(handle.current!.readSnapshot().expandedCursor).toBe("before :dizzy".length); + await act(() => replace("before 💫 after", "before 💫".length)); + expect(handle.current!.readSnapshot()).toMatchObject({ + value: "before 💫 after", + expandedCursor: "before 💫".length, + }); + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + expect(document.activeElement).toBe(textbox); + await act(() => + textbox.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + code: "KeyZ", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ), + ); + expect(handle.current!.readSnapshot().value).toBe("before :dizzy after"); + }); + + it("suppresses suggestions when a text selection keeps its anchor at the same caret", async () => { + await act(() => handle.current!.focus()); + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + const selection = window.getSelection()!; + const range = selection.getRangeAt(0); + const selected = document.createRange(); + selected.setStart(range.startContainer, range.startOffset); + selected.setEnd(range.startContainer, range.startOffset + 2); + await act(async () => { + selection.removeAllRanges(); + selection.addRange(selected); + document.dispatchEvent(new Event("selectionchange")); + await Promise.resolve(); + }); + expect(textbox.contains(selection.anchorNode)).toBe(true); + expect(handle.current!.suggestionsSuppressed()).toBe(true); + expect(changes.at(-1)).toMatchObject({ suppressed: true }); + }); + + it("closes suggestions during composition and rechecks the committed caret afterward", async () => { + await act(() => handle.current!.focus()); + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + await act(() => + textbox.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })), + ); + expect(changes.at(-1)).toMatchObject({ suppressed: true }); + expect(handle.current!.suggestionsSuppressed()).toBe(true); + await act(async () => { + textbox.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })); + await Promise.resolve(); + }); + expect(handle.current!.suggestionsSuppressed()).toBe(false); + expect(changes.at(-1)).toMatchObject({ value: "before :dizzy after", suppressed: false }); + }); +}); + +it("changes a real fence language picker while preserving metadata, code, and editor focus", async () => { + const draft = "```ts title=x\nconst x = 1\n```"; + await act(() => { + enableRichText(true); + replace(draft, draft.length); + }); + const trigger = container.querySelector( + 'button[aria-label="Code language: TypeScript"]', + )!; + expect(trigger).not.toBeNull(); + await act(() => trigger.click()); + const python = [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes("Python"), + )!; + expect(python).toBeDefined(); + await act(() => python.click()); + expect(handle.current!.readSnapshot().value).toBe("```python title=x\nconst x = 1\n```"); + expect(document.activeElement).toBe(container.querySelector('[data-testid="composer-editor"]')); +}); + +it("opens a real file chip through its application action provider", async () => { + const source = serializeComposerFileLink("src/example.ts"); + await act(() => replace(`${source} after`, `${source} `.length)); + const button = container.querySelector( + '[aria-label="Preview src/example.ts"]', + )!; + expect(button).not.toBeNull(); + await act(() => button.click()); + expect(openMention).toHaveBeenCalledWith("src/example.ts"); + expect(handle.current!.readSnapshot().value).toBe(`${source} after`); +}); + +it("copies canonical chip Markdown, cuts once, and restores it with native undo", async () => { + const source = `${serializeComposerFileLink("src/example.ts")} after`; + await act(() => { + replace(source, source.length); + handle.current!.focus(); + }); + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + await act(() => + textbox.dispatchEvent( + new KeyboardEvent("keydown", { + key: "a", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ), + ); + expect(handle.current!.readSelectionRange()).toEqual({ start: 0, end: source.length }); + const data = new Map(); + const event = new Event("cut", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { setData: (kind: string, text: string) => data.set(kind, text) }, + }); + await act(() => textbox.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + expect(data.get("text/plain")).toBe(source); + expect(handle.current!.readSnapshot().value).toBe(""); + await act(() => + textbox.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ), + ); + expect(handle.current!.readSnapshot().value).toBe(source); +}); + +it("Shift+Tabs from a citation caret to its real comment trigger before plan-mode handling", async () => { + const router = createRouter({ + routeTree: createRootRoute({ component: ComposerProbe }), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + await act(() => root.render()); + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "Quoted text", + start: 0, + end: 11, + prefix: "", + suffix: "", + }); + const source = `${citation} after`; + await act(() => { + replace(source, collapseExpandedComposerCursor(source, citation.length)); + }); + await act(() => handle.current!.focus()); + expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length); + commandKey.mockImplementation((key, event) => key === "Tab" && event.shiftKey); + const textbox = container.querySelector('[data-testid="composer-editor"]')!; + const commentButton = container.querySelector("[data-citation-comment-trigger]")!; + expect(commentButton).not.toBeNull(); + const shiftTab = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + await act(() => textbox.dispatchEvent(shiftTab)); + expect(shiftTab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(commentButton); + expect(commandKey).not.toHaveBeenCalled(); + expect(handle.current!.readSnapshot()).toMatchObject({ + value: source, + expandedCursor: citation.length, + }); + const tab = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); + await act(() => commentButton.dispatchEvent(tab)); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(textbox); + expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length); + await act(() => handle.current!.focusAtEnd()); + await act(() => + textbox.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }), + ), + ); + expect(commandKey).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index cb4810e6f17e7778e3413c34a30daae5ba2e9aaa..6e0d1af8d96b172e38f6350d1d1839d9ab32ff53 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,17 +1,628 @@ -import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap"; -import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap"; - -export type { - ComposerCitationCommentRequest, - ComposerPromptEditorHandle, - ComposerPromptEditorProps, -} from "./ComposerPromptEditorTiptap"; - -/** - * The composer editor. Tiptap in both modes: the `richTextEnabled` setting - * toggles Markdown styling, never the engine. Plain mode keeps Markdown - * markers literal; `literalText` also preserves context-token sources as text. - */ +import type { ComposerContextClipboardFragment, ServerProviderSkill } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextClipboardHtml, +} from "@t3tools/shared/composerContextClipboard"; +import { + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; +import { isolateHistory } from "@codemirror/commands"; +import { Transaction } from "@codemirror/state"; +import { createPortal } from "react-dom"; +import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; + +import { expandCollapsedComposerCursor } from "~/composer-logic"; +import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { basenameOfPath } from "~/pierre-icons"; +import { cn } from "~/lib/utils"; +import { + ComposerContextActionsContext, + ComposerContextRecordsContext, + ComposerContextReferenceChip, + type ComposerDraftContextRecords, +} from "./composerContextPresentation"; +import { + createComposerMarkdownEditor, + composerSuggestionsSuppressed, + composerTokens, + type ComposerChip, + type ComposerToken, +} from "./composerMarkdownEditor"; +import { ContextChip } from "./ContextChip"; +import { ContextChipPopover } from "./contextChipParts"; +import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; +import { FileTagChipContent } from "./chat/FileTagChip"; +import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; +import { ComposerCodeBlockLanguagePicker } from "./chat/ComposerCodeBlockLanguagePicker"; +import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; +import { SkillChipIcon } from "./chat/SkillInlineText"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface ComposerPromptEditorHandle { + focus: () => void; + focusAt: (cursor: number) => void; + focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; + suggestionsSuppressed: () => boolean; + requestCitationComment: (request: ComposerCitationCommentRequest) => void; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + contextIds: string[]; + }; + /** + * True when a collapsed caret sits on the first ("start") or last ("end") + * visual line, counting soft wraps. Prompt history only claims ArrowUp and + * ArrowDown at these edges so arrows still move the caret inside multiline + * text. + */ + isCaretOnVisualEdge: (edge: "start" | "end") => boolean; +} + +export interface ComposerPromptEditorProps { + value: string; + cursor: number; + /** + * Render Markdown styling (bold, italic, code, strike, task checkboxes). + * Off renders the same CodeMirror engine as plain text: every marker stays a + * literal character. + */ + richTextEnabled?: boolean; + /** Preserve editor answers verbatim, without Markdown or context chips. */ + literalText?: boolean; + /** Draft records behind the prompt's context references, keyed by context id. */ + contextRecords: ComposerDraftContextRecords; + /** Structured clipboard payload for the given referenced ids, or null to skip. */ + buildContextClipboardFragment?: + | ((contextIds: ReadonlyArray) => string | null) + | undefined; + /** Imports a structured paste's records; returns ids that changed. */ + importContextFragment?: + | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) + | undefined; + skills: ReadonlyArray; + disabled: boolean; + placeholder: string; + ariaLabel?: string | undefined; + /** Identifies an editor with suggestions, even while its list is closed. */ + suggestionListId?: string | undefined; + /** References the highlighted option only while its list is rendered. */ + activeSuggestionId?: string | undefined; + containerClassName?: string; + className?: string; + placeholderClassName?: string; + onChange: ( + nextValue: string, + nextCursor: number, + expandedCursor: number, + suppressSuggestions: boolean, + contextIds: string[], + ) => void; + onVisibleSelectionChange?: () => void; + onCommandKeyDown?: (key: string, event: KeyboardEvent) => boolean; + onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; + onPageScrollKeyUp?: (key: string) => void; + onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; + onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; + onPaste: React.ClipboardEventHandler; + editorRef: React.RefObject; +} + +export type ComposerCitationCommentRequest = { + value: string; + citationStart: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: CitationInsertedSpaces; +}; + +type CitationInsertedSpaces = { before: boolean; after: boolean }; +type CitationComment = { + start: number; + sourceAnchor?: AssistantCitationSourceAnchor | undefined; + insertedSpaces?: CitationInsertedSpaces | undefined; +}; + export function ComposerPromptEditor(props: ComposerPromptEditorProps) { - return ; + const host = useRef(null); + const currentProps = useRef(props); + const editor = useRef | null>(null); + const pendingCitation = useRef(null); + const citationDraft = useRef<{ start: number; comment: string } | null>(null); + const [comment, setComment] = useState(null); + const [chips, setChips] = useState([]); + + useLayoutEffect(() => { + currentProps.current = props; + }); + useLayoutEffect(() => { + let alive = true; + const instance = createComposerMarkdownEditor({ + props: () => currentProps.current, + mountChip: (chip) => { + if (alive) setChips((chips) => [...chips, chip]); + }, + unmountChip: (dom) => { + if (alive) setChips((chips) => chips.filter((chip) => chip.dom !== dom)); + }, + }); + editor.current = instance; + host.current!.append(instance.dom); + return () => { + alive = false; + editor.current = null; + instance.destroy(); + }; + }, []); + + // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props. + useLayoutEffect(() => { + editor.current?.configure(); + }, [ + props.disabled, + props.richTextEnabled, + props.literalText, + props.className, + props.ariaLabel, + props.suggestionListId, + props.activeSuggestionId, + props.placeholder, + ]); + // oxlint-enable react/exhaustive-effect-dependencies + useLayoutEffect(() => { + const instance = editor.current; + if (!instance) return; + const snapshot = instance.snapshot(); + if (snapshot.value !== props.value) { + const draft = citationDraft.current; + citationDraft.current = null; + const saveDraft = currentProps.current.onCitationDraftSave; + if (draft && saveDraft) { + const token = composerTokens(instance.view.state, false).find( + (token) => token.type === "citation" && token.start === draft.start, + ); + if (token?.type === "citation") { + const dismissal = resolveAssistantCitationCommentDismissal({ + reason: "none", + draft: draft.comment, + savedComment: token.citation.comment, + }); + if (dismissal.kind === "commit") { + const source = serializeAssistantCitation( + withAssistantCitationComment(token.citation, dismissal.comment), + ); + saveDraft( + snapshot.value, + snapshot.value.slice(0, token.start) + source + snapshot.value.slice(token.end), + ); + } + } + } + setComment(null); + let from = 0; + while ( + from < snapshot.value.length && + from < props.value.length && + snapshot.value[from] === props.value[from] + ) + from += 1; + let oldEnd = snapshot.value.length; + let newEnd = props.value.length; + while ( + oldEnd > from && + newEnd > from && + snapshot.value[oldEnd - 1] === props.value[newEnd - 1] + ) { + oldEnd -= 1; + newEnd -= 1; + } + instance.view.dispatch({ + changes: { from, to: oldEnd, insert: props.value.slice(from, newEnd) }, + selection: { + anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText), + }, + annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")], + }); + } else if (snapshot.cursor !== props.cursor && !instance.view.compositionStarted) { + instance.view.dispatch({ + selection: { + anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText), + }, + annotations: Transaction.userEvent.of("t3.controlled"), + }); + } + const request = pendingCitation.current; + if ( + request?.value === props.value && + composerTokens(instance.view.state, props.literalText ?? false).some( + (token) => token.type === "citation" && token.start === request.citationStart, + ) + ) { + pendingCitation.current = null; + setComment({ + start: request.citationStart, + sourceAnchor: request.sourceAnchor, + insertedSpaces: request.insertedSpaces, + }); + } + }, [props.value, props.cursor, props.literalText]); + + useLayoutEffect(() => { + editor.current?.view.requestMeasure(); + }); + + useImperativeHandle( + props.editorRef, + () => ({ + focus: () => editor.current?.focus(), + focusAt: (cursor) => { + const instance = editor.current; + if (!instance) return; + const snapshot = instance.snapshot(); + instance.view.dispatch({ + selection: { + anchor: expandCollapsedComposerCursor( + snapshot.value, + cursor, + currentProps.current.literalText, + ), + }, + scrollIntoView: true, + }); + instance.focus(); + }, + focusAtEnd: () => { + const instance = editor.current; + if (!instance) return; + instance.view.dispatch({ + selection: { anchor: instance.view.state.doc.length }, + scrollIntoView: true, + }); + instance.focus(); + }, + readSnapshot: () => + editor.current?.snapshot() ?? { + value: props.value, + cursor: props.cursor, + expandedCursor: expandCollapsedComposerCursor( + props.value, + props.cursor, + props.literalText, + ), + contextIds: [], + }, + readSelectionRange: () => { + const range = editor.current?.view.state.selection.main; + return { start: range?.from ?? 0, end: range?.to ?? 0 }; + }, + suggestionsSuppressed: () => + !editor.current || + composerSuggestionsSuppressed( + editor.current.view, + currentProps.current.literalText ?? false, + ), + requestCitationComment: (request) => { + pendingCitation.current = request; + if (editor.current?.snapshot().value === request.value) { + pendingCitation.current = null; + setComment({ + start: request.citationStart, + sourceAnchor: request.sourceAnchor, + insertedSpaces: request.insertedSpaces, + }); + } + }, + isCaretOnVisualEdge: (edge) => { + const instance = editor.current; + if (!instance || !instance.view.state.selection.main.empty) return false; + const head = instance.view.state.selection.main.head; + if (instance.view.state.doc.length === 0) return true; + const line = instance.view.state.doc.lineAt(head); + if (edge === "start" ? line.number !== 1 : line.number !== instance.view.state.doc.lines) + return false; + const caret = instance.view.coordsAtPos(head); + const boundary = instance.view.coordsAtPos( + edge === "start" ? 0 : instance.view.state.doc.length, + ); + return ( + caret !== null && + boundary !== null && + Math.abs(caret.top - boundary.top) < Math.max(1, (caret.bottom - caret.top) / 2) + ); + }, + }), + [props.editorRef, props.value, props.cursor, props.literalText], + ); + + const copy = (event: React.ClipboardEvent, cut: boolean) => { + const instance = editor.current; + if (!instance || (cut && props.disabled)) return; + const range = instance.view.state.selection.main; + if (range.empty) return; + const text = instance.view.state.sliceDoc(range.from, range.to); + const ids = props.literalText ? [] : collectInlineContextIds(text); + const fragment = ids.length > 0 ? props.buildContextClipboardFragment?.(ids) : null; + event.preventDefault(); + event.stopPropagation(); + event.clipboardData.setData("text/plain", text); + if (fragment) { + event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); + event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); + } + if (cut) + instance.view.dispatch({ + ...instance.view.state.replaceSelection(""), + annotations: isolateHistory.of("full"), + userEvent: "delete.cut", + scrollIntoView: true, + }); + }; + + return ( + +
copy(event, false)} + onCutCapture={(event) => copy(event, true)} + onKeyDown={(event) => { + if (["Control", "Meta", "Alt", "Shift"].includes(event.key)) + props.onPageScrollRelease?.(); + }} + onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)} + onBlur={props.onPageScrollRelease} + > +
+ {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? ( +
+ {props.placeholder} +
+ ) : null} +
+ {chips.map((chip) => + chip.kind === "fence" + ? createPortal( + { + const instance = editor.current; + if ( + !instance || + props.disabled || + instance.view.state.sliceDoc(chip.block.infoFrom, chip.block.infoTo) !== + chip.block.info + ) + return; + const info = withInfoStringLanguage(chip.block.info, language); + const offset = info.length - (chip.block.infoTo - chip.block.infoFrom); + instance.view.dispatch({ + changes: { from: chip.block.infoFrom, to: chip.block.infoTo, insert: info }, + selection: { anchor: chip.block.codeTo + offset }, + annotations: isolateHistory.of("full"), + userEvent: "input.fence-language", + }); + instance.focus(); + }} + />, + chip.dom, + `fence:${chip.block.from}`, + ) + : createPortal( + { + citationDraft.current = null; + setComment(open ? { start: chip.token.start } : null); + }} + onDraftChange={(comment) => { + citationDraft.current = { start: chip.token.start, comment }; + }} + onSaveComment={(text) => { + const instance = editor.current; + const token = chip.token; + if ( + !instance || + props.disabled || + token.type !== "citation" || + instance.view.state.sliceDoc(token.start, token.end) !== token.source + ) + return false; + citationDraft.current = null; + const source = serializeAssistantCitation( + withAssistantCitationComment(token.citation, text), + ); + instance.view.dispatch({ + changes: { from: token.start, to: token.end, insert: source }, + annotations: isolateHistory.of("full"), + userEvent: "input.citation", + }); + return true; + }} + onCancel={() => { + const instance = editor.current; + if ( + !instance || + props.disabled || + comment?.start !== chip.token.start || + !comment.insertedSpaces + ) + return; + const token = chip.token; + const from = + comment.insertedSpaces.before && + instance.view.state.sliceDoc(token.start - 1, token.start) === " " + ? token.start - 1 + : token.start; + const to = + comment.insertedSpaces.after && + instance.view.state.sliceDoc(token.end, token.end + 1) === " " + ? token.end + 1 + : token.end; + instance.view.dispatch({ + changes: { from, to }, + selection: { anchor: from }, + annotations: isolateHistory.of("full"), + userEvent: "delete.citation", + }); + instance.focus(); + }} + onRestoreFocus={() => { + const instance = editor.current; + if (!instance || props.disabled) return; + const token = composerTokens( + instance.view.state, + props.literalText ?? false, + ).find((token) => token.start === chip.token.start); + instance.view.dispatch({ + selection: { + anchor: + token?.end ?? Math.min(chip.token.start, instance.view.state.doc.length), + }, + scrollIntoView: true, + }); + instance.focus(); + }} + onSubmitAndSend={props.onCitationSubmitAndSend} + />, + chip.dom, + `${chip.token.start}:${chip.token.type}`, + ), + )} + + ); +} + +function ComposerTokenChip(props: { + token: ComposerToken; + skills: readonly ServerProviderSkill[]; + disabled: boolean; + comment: CitationComment | null; + onCommentChange: (open: boolean) => void; + onDraftChange: (comment: string) => void; + onSaveComment: (text: string) => boolean; + onCancel: () => void; + onRestoreFocus: () => void; + onSubmitAndSend: (() => void) | undefined; +}) { + const actions = use(ComposerContextActionsContext); + const token = props.token; + if (token.type === "context-reference") + return ( + + ); + if (token.type === "citation") + return ( + { + if ( + event.key === "Tab" && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + props.onRestoreFocus(); + } + }} + > + { + if (!props.disabled || !open) props.onCommentChange(open); + }, + ...(props.comment?.insertedSpaces ? { onCancel: props.onCancel } : {}), + onSave: props.onSaveComment, + onDraftChange: props.onDraftChange, + onSaveAndSend: (text) => { + if (!props.onSaveComment(text)) return false; + props.onSubmitAndSend?.(); + return true; + }, + onRestoreFocus: props.onRestoreFocus, + }} + /> + + ); + if (token.type === "skill") { + const skill = props.skills.find((skill) => skill.name === token.value); + const label = formatProviderSkillDisplayName(skill ?? { name: token.value }); + return ( + } + label={label} + accessibleLabel={`Skill ${label}`} + > +
+

{label}

+

{skill?.description ?? "No description is available for this skill."}

+ {skill?.path ? ( + + ) : null} +
+
+ ); + } + const path = token.value; + return ( + + } + onClick={() => actions.openMention(path)} + aria-label={`Preview ${path}`} + data-composer-mention-chip="true" + /> + } + > + + + + + + + ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx index 81abba7e0d2093717d157f49129aad4007b0469f..f019309cba2a84dabfd258f6cfb42a544637b13d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx @@ -48,6 +48,14 @@ let container: HTMLDivElement; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(), + }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -75,6 +83,7 @@ function Composer({ root.render()); await act(async () => editorRef.current?.requestCitationComment({ - previousValue: "See ", value: promptBefore, citationStart: "See ".length, sourceAnchor: {} as AssistantCitationSourceAnchor, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx deleted file mode 100644 index 53bc7104cbc3aac5ef80252c150be29b9e72ac10..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment jsdom - -import type { Editor } from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; -import { act, createRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { serializeEditorDoc } from "~/composer-rich-text-doc"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; - -const draft = - "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function mountEditor(value: string, richTextEnabled = true) { - await act(async () => - root.render( - ()} - value={value} - cursor={0} - richTextEnabled={richTextEnabled} - contextRecords={new Map()} - skills={[]} - disabled={false} - placeholder="" - onChange={() => {}} - onPaste={() => {}} - />, - ), - ); - const element = container.querySelector(".tiptap"); - if (!element) throw new Error("Composer did not mount"); - return element.editor; -} - -function press(editor: Editor, key: string, options: KeyboardEventInit = {}) { - const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options }); - const handled = editor.view.props.handleKeyDown?.(editor.view, event); - expect(event.defaultPrevented).toBe(Boolean(handled)); - return handled; -} - -it("keeps Up before the opening bold marker in a quote, including while typing", async () => { - const editor = await mountEditor(draft); - const position = editor.state.selection.from; - await act(async () => { - editor.view.focus(); - expect(press(editor, "ArrowUp")).toBe(true); - expect(editor.state.selection.from).toBe(position); - editor.view.updateState(editor.state); - const marker = editor.view.dom.querySelector(".composer-rich-marker"); - const caret = window.getSelection()?.getRangeAt(0); - if (!marker || !caret) throw new Error("Caret or bold marker is missing"); - const markerRange = document.createRange(); - markerRange.selectNode(marker); - expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0); - // A repeated Up stays at the outside stop, ready for native navigation. - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**")); -}); - -it("keeps Down after a closing marker, including while typing", async () => { - const editor = await mountEditor("> **bold**"); - await act(async () => { - editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); - expect(press(editor, "ArrowDown")).toBe(true); - expect(press(editor, "ArrowDown")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x"); -}); - -it("leaves vertical navigation between paragraphs to the browser", async () => { - const editor = await mountEditor("**first**\n**second**"); - await act(async () => { - const first = editor.state.doc.child(0); - editor.commands.setTextSelection(first.nodeSize + 1); - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.setTextSelection(first.nodeSize - 1); - expect(press(editor, "ArrowDown")).toBe(false); - }); -}); - -it.each([ - { shiftKey: true }, - { altKey: true }, - { metaKey: true }, - { ctrlKey: true }, - { isComposing: true }, -])("preserves modified Up behavior for %j", async (options) => { - const editor = await mountEditor(draft); - await act(async () => { - expect(press(editor, "ArrowUp", options)).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); -}); - -it("leaves literal Markdown navigation to the browser in plain mode", async () => { - const editor = await mountEditor(draft, false); - await act(async () => { - editor.commands.setTextSelection(5); - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); -}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx deleted file mode 100644 index a9accaf6e013ce425f1fafee1dac211163e54ea0..0000000000000000000000000000000000000000 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ /dev/null @@ -1,1985 +0,0 @@ -import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; -import { - Extension, - findChildren, - InputRule, - Node, - wrappingInputRule, - type JSONContent, -} from "@tiptap/core"; -import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { - type EditorState, - NodeSelection, - Plugin, - PluginKey, - TextSelection, -} from "@tiptap/pm/state"; -import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; -import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; -import type { - AssistantCitation, - ComposerContextClipboardFragment, - ServerProviderSkill, -} from "@t3tools/contracts"; -import { - serializeAssistantCitation, - withAssistantCitationComment, -} from "@t3tools/shared/assistantCitations"; -import { - COMPOSER_CONTEXT_CLIPBOARD_MIME, - encodeComposerContextClipboardHtml, -} from "@t3tools/shared/composerContextClipboard"; -import { - createContext, - use, - useCallback, - useEffect, - useEffectEvent, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, -} from "react"; -import { EditorContent, useEditor } from "@tiptap/react"; - -import { - clampCollapsedComposerCursor, - collapseExpandedComposerCursor, - expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToInlineToken, - pastedPathQueryLength, -} from "~/composer-logic"; -import { - collectComposerPromptInlineTokens, - selectionTouchesMentionBoundary, -} from "~/composer-editor-mentions"; -import { - buildDocJson, - ComposerBlockExtensions, - ComposerCodeBlockExtension, - ComposerListExtensions, - buildTiptapContent, - collapsedToFlat, - caretTakesMarksBefore, - convertBulletItemToTask, - ComposerCodeExtension, - ComposerTaskItemExtension, - ComposerTaskListExtension, - flatToCollapsed, - flatToMarkdown, - flatToPm, - pmToFlat, - serializeEditorDoc, - serializeSelection, - splitOrLiftListItem, - stepCaretAcrossStyledEdge, - type SkillMeta, -} from "~/composer-rich-text-doc"; -import { - convertCodeFenceOnEnter, - exitCodeBlockOnClosingFence, - exitCodeBlockOnTrailingBlankLines, - indentCodeBlock, - indentedNewlineInCodeBlock, - selectionInOneCodeBlock, -} from "~/composer-code-block"; -import { - COMPOSER_UNDO_GROUP_DELAY, - type ComposerChangeKind, - groupUndoByChangeKind, - markAsClipboardEdit, -} from "~/composer-undo-grouping"; -import { collectInlineContextIds } from "~/lib/composerContextReferences"; -import { resolveDiffThemeName } from "~/lib/diffRendering"; -import { cn, isMacPlatform } from "~/lib/utils"; -import { basenameOfPath } from "~/pierre-icons"; -import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; -import { FileTagChipContent } from "./chat/FileTagChip"; -import { SkillChipIcon } from "./chat/SkillInlineText"; -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import { getTimelinePageScrollKey } from "./chat/pageScrollController"; -import { ContextChipPopover } from "./contextChipParts"; -import { Button } from "./ui/button"; -import { ContextChip } from "./ContextChip"; -import { - ComposerContextActionsContext, - ComposerContextReferenceChip, - ComposerContextRecordsContext, -} from "./composerContextPresentation"; -import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; -import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; -import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; -import { importPastedComposerText } from "./composerInlineTokenPaste"; -import { didComposerSelectionChangeVisibly } from "./composerSelection"; -import type { ComposerDraftContextRecords } from "./composerContextPresentation"; - -export interface ComposerPromptEditorHandle { - focus: () => void; - focusAt: (cursor: number) => void; - focusAtEnd: () => void; - readSelectionRange: () => { start: number; end: number }; - requestCitationComment: (request: ComposerCitationCommentRequest) => void; - readSnapshot: () => { - value: string; - cursor: number; - expandedCursor: number; - contextIds: string[]; - }; - /** - * True when a collapsed caret sits on the first ("start") or last ("end") - * visual line, counting soft wraps. Prompt history only claims ArrowUp and - * ArrowDown at these edges so arrows still move the caret inside multiline - * text. - */ - isCaretOnVisualEdge: (edge: "start" | "end") => boolean; -} - -export interface ComposerPromptEditorProps { - value: string; - cursor: number; - /** - * Render Markdown styling (bold, italic, code, strike, task checkboxes). - * Off renders the same Tiptap engine as plain text: every marker stays a - * literal character. - */ - richTextEnabled?: boolean; - /** Preserve editor answers verbatim, without Markdown or context chips. */ - literalText?: boolean; - /** Draft records behind the prompt's context references, keyed by context id. */ - contextRecords: ComposerDraftContextRecords; - /** Structured clipboard payload for the given referenced ids, or null to skip. */ - buildContextClipboardFragment?: - | ((contextIds: ReadonlyArray) => string | null) - | undefined; - /** Imports a structured paste's records; returns ids that changed. */ - importContextFragment?: - | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap) - | undefined; - skills: ReadonlyArray; - disabled: boolean; - placeholder: string; - ariaLabel?: string | undefined; - /** Identifies an editor with suggestions, even while its list is closed. */ - suggestionListId?: string | undefined; - /** References the highlighted option only while its list is rendered. */ - activeSuggestionId?: string | undefined; - /** Reads suggestion state at paste time, including Escape dismissal. */ - isPathQueryActive?: () => boolean; - containerClassName?: string; - className?: string; - placeholderClassName?: string; - onChange: ( - nextValue: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - contextIds: string[], - ) => void; - onVisibleSelectionChange?: () => void; - onCommandKeyDown?: (key: string, event: KeyboardEvent, isTaskItem?: boolean) => boolean; - onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; - onPageScrollKeyUp?: (key: string) => void; - onPageScrollRelease?: () => void; - onCitationSubmitAndSend?: () => void; - /** - * Called when a new controlled value replaces the document while a citation - * comment is unsaved. The comment is saved into `replacedValue`, giving - * `savedValue`, so the owner of `replacedValue` can keep it. - */ - onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; - onPaste: React.ClipboardEventHandler; - editorRef: React.RefObject; -} - -export type ComposerCitationCommentRequest = { - previousValue: string; - value: string; - citationStart: number; - sourceAnchor: AssistantCitationSourceAnchor; - insertedSpaces: CitationInsertedSpaces; -}; - -// Spaces added around a freshly inserted citation, removed with it if its comment is cancelled. -type CitationInsertedSpaces = { before: boolean; after: boolean }; - -type OpenCitationComment = { - key: string; - sourceAnchor?: AssistantCitationSourceAnchor; - removeOnCancel?: CitationInsertedSpaces; -}; - -const ComposerCitationCommentContext = createContext<{ - openComment: OpenCitationComment | null; - onOpenChange: (citeKey: string, open: boolean) => void; - onDraftChange: (citeKey: string, comment: string) => void; - onSubmitAndSend: () => void; -}>({ - openComment: null, - onOpenChange: () => {}, - onDraftChange: () => {}, - onSubmitAndSend: () => {}, -}); - -const RichComposerSkillsContext = createContext>([]); - -const SURROUND_CLOSE: Record = { - "(": ")", - "[": "]", - "{": "}", - "'": "'", - '"': '"', - "“": "”", - "`": "`", - "<": ">", - "«": "»", - "*": "*", - _: "_", -}; - -function resolvedThemeFromDocument(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - -// ── Inline atom nodes (chips) ───────────────────────────────────────────── - -/** - * Wraps an inline chip node view: keeps the caret and text selection out of the chip and - * paints the editor's node selection over it. - */ -const CHIP_NODE_SELECTION_CLASS_NAME = - "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-sm data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; - -const ComposerMentionExtension = Node.create({ - name: "composer-mention", - group: "inline", - inline: true, - atom: true, - selectable: true, - addAttributes() { - return { - path: { default: "" }, - source: { default: "" }, - }; - }, - parseHTML() { - return [{ tag: "span[data-composer-mention]" }]; - }, - renderHTML({ HTMLAttributes }) { - return ["span", { "data-composer-mention": "", ...HTMLAttributes }]; - }, - addNodeView() { - return ReactNodeViewRenderer(ComposerMentionNodeView); - }, -}); - -function ComposerMentionNodeView({ node }: NodeViewProps) { - const actions = use(ComposerContextActionsContext); - const path = (node.attrs.path as string) ?? ""; - const chip = ( - } - onClick={() => actions.openMention(path)} - aria-label={`Preview ${path}`} - contentEditable={false} - spellCheck={false} - data-composer-mention-chip="true" - > - - - ); - return ( - - - - - - - - - ); -} - -const ComposerSkillExtension = Node.create({ - name: "composer-skill", - group: "inline", - inline: true, - atom: true, - selectable: true, - addAttributes() { - return { - skillName: { default: "" }, - skillLabel: { default: "" }, - skillDescription: { default: null }, - }; - }, - parseHTML() { - return [{ tag: "span[data-composer-skill]" }]; - }, - renderHTML({ HTMLAttributes }) { - return ["span", { "data-composer-skill": "", ...HTMLAttributes }]; - }, - addNodeView() { - return ReactNodeViewRenderer(ComposerSkillNodeView); - }, -}); - -function ComposerSkillNodeView({ node }: NodeViewProps) { - const actions = use(ComposerContextActionsContext); - const skills = use(RichComposerSkillsContext); - const skillName = (node.attrs.skillName as string) ?? ""; - const skillLabel = (node.attrs.skillLabel as string) || skillName; - const skillDescription = (node.attrs.skillDescription as string | null) ?? null; - const skill = skills.find((candidate) => candidate.name === skillName); - return ( - - } - label={skillLabel} - accessibleLabel={`Skill ${skillLabel}`} - > -
-

{skillLabel}

-

- {skill?.description ?? - skillDescription ?? - "No description is available for this skill."} -

- {skill?.path ? ( - - ) : null} -
-
-
- ); -} - -const ComposerCitationExtension = Node.create({ - name: "composer-citation", - group: "inline", - inline: true, - atom: true, - selectable: true, - addAttributes() { - return { - citation: { default: null }, - source: { default: "" }, - citeKey: { default: "" }, - }; - }, - parseHTML() { - return [{ tag: "span[data-composer-citation]" }]; - }, - renderHTML({ HTMLAttributes }) { - return ["span", { "data-composer-citation": "", ...HTMLAttributes }]; - }, - addNodeView() { - return ReactNodeViewRenderer(ComposerCitationNodeView); - }, -}); - -function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { - const commentContext = use(ComposerCitationCommentContext); - const citation = node.attrs.citation as AssistantCitation; - const citeKey = node.attrs.citeKey as string; - const commentTarget = - commentContext.openComment?.key === citeKey ? commentContext.openComment : null; - - const nodePos = useCallback(() => { - const pos = typeof getPos === "function" ? getPos() : null; - return typeof pos === "number" ? pos : null; - }, [getPos]); - - const onSaveComment = useCallback( - (comment: string): boolean => { - if (!editor.isEditable) return false; - const pos = nodePos(); - if (pos === null) return false; - const current = editor.state.doc.nodeAt(pos); - if (!current || current.type.name !== "composer-citation") return false; - const currentCitation = current.attrs.citation as AssistantCitation; - const next = withAssistantCitationComment(currentCitation, comment); - const tr = editor.state.tr.setNodeMarkup(pos, undefined, { - ...current.attrs, - citation: next, - source: serializeAssistantCitation(next), - }); - editor.view.dispatch(tr); - return true; - }, - [editor, nodePos], - ); - - // Undo a fresh insertion: drop the chip and the spaces inserted with it. - const removeOnCancel = commentTarget?.removeOnCancel; - const onRemove = useCallback(() => { - if (!editor.isEditable || !removeOnCancel) return; - const pos = nodePos(); - if (pos === null) return; - const { doc } = editor.state; - const current = doc.nodeAt(pos); - if (!current) return; - const end = pos + current.nodeSize; - const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos; - const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end; - editor.chain().focus().deleteRange({ from, to }).run(); - }, [editor, nodePos, removeOnCancel]); - - // Put the caret right after the chip so Enter sends and typing continues the prompt. - const onRestoreFocus = useCallback(() => { - if (!editor.isEditable) return; - const pos = nodePos(); - if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); - if (!current || current.type.name !== "composer-citation") return; - editor.commands.focus(pos + current.nodeSize); - }, [editor, nodePos]); - - return ( - ) => { - // Tab from the comment button returns to the caret after the chip. - if ( - !editor.isEditable || - event.key !== "Tab" || - event.shiftKey || - event.altKey || - event.metaKey || - event.ctrlKey || - !(event.target instanceof HTMLElement) || - event.target.dataset.citationCommentTrigger === undefined - ) { - return; - } - event.preventDefault(); - onRestoreFocus(); - }} - > - { - if (open && !editor.isEditable) return; - commentContext.onOpenChange(citeKey, open); - }, - ...(removeOnCancel ? { onCancel: onRemove } : {}), - onSave: onSaveComment, - onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), - onSaveAndSend: (comment) => { - if (!onSaveComment(comment)) return false; - commentContext.onSubmitAndSend(); - return true; - }, - onRestoreFocus, - }} - /> - - ); -} - -const ComposerContextReferenceExtension = Node.create({ - name: "composer-context-reference", - group: "inline", - inline: true, - atom: true, - selectable: true, - addAttributes() { - return { - kind: { default: "" }, - contextId: { default: "" }, - label: { default: "" }, - source: { default: "" }, - }; - }, - parseHTML() { - return [{ tag: "span[data-composer-context-reference]" }]; - }, - renderHTML({ HTMLAttributes }) { - return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }]; - }, - addNodeView() { - return ReactNodeViewRenderer(ComposerContextReferenceNodeView); - }, -}); - -function ComposerContextReferenceNodeView({ node }: NodeViewProps) { - return ( - - - - ); -} - -// ── Marker reveal (show ** when the cursor is on styled text) ────────────── - -type StyledRange = { - from: number; - to: number; - markers: { at: number; side: number; text: string }[]; -}; - -function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { - const ranges: StyledRange[] = []; - const map = serializeEditorDoc(doc); - let range: StyledRange | null = null; - let openLength = 0; - for (const run of map.runs) { - // A fence is drawn as a block, not revealed a character at a time, so its - // delimiters never become marker widgets. - if (run.nodeName === "codeBlock") continue; - if (run.openLen > 0) { - range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; - range.markers.push({ - at: run.pmPos, - side: -1, - text: map.value.slice(run.mdStart, run.mdStart + run.openLen), - }); - } - if (range === null) continue; - range.to = run.pmPos + run.docLen; - if (run.closeLen > 0) { - const end = run.mdStart + run.mdLen; - range.markers.push({ - at: range.to, - side: -2, - text: map.value.slice(end - run.closeLen, end), - }); - } - openLength += run.openLen - run.closeLen; - if (openLength === 0) { - ranges.push(range); - range = null; - } - } - return ranges; -} - -/** - * A typed marker becomes a list item that remembers the marker it was typed - * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. - * - * `- ` alone is not enough for a dash: it waits for the first character after - * the space, which it carries into the new item. That is what lets the GFM - * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, - * instead of being cut off by an instant bullet. A `- ` left on its own is - * still a bullet the next time the draft is rebuilt. - */ -function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { - return new InputRule({ - find, - handler: ({ state, range, match, chain }) => { - const marker = match.groups?.marker ?? "-"; - const space = match.groups?.space ?? " "; - const carried = match.groups?.carried ?? ""; - // Top-level paragraphs only: inside an item or a quote the new list - // would nest under a line the stored draft writes flat. - const $from = state.doc.resolve(range.from); - if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; - const command = chain() - .deleteRange(range) - .wrapInList( - listType, - listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, - ) - .updateAttributes("listItem", { marker, space }); - (carried ? command.insertContent(carried) : command).run(); - return undefined; - }, - }); -} - -/** - * `[ ] ` at the start of an existing bullet item turns it into a task, for - * items that were already a list when the checkbox was wanted. New tasks are - * typed whole, `- [ ] `, and reach the task rule directly. - */ -const bulletToTaskInputRule = new InputRule({ - find: /^\[([ xX])\] $/, - handler: ({ state, range, match, chain }) => { - const $from = state.doc.resolve(range.from); - const item = $from.node(-1); - if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; - // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` - // item comes back out as `- [ ]`. - if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; - const checked = (match[1] ?? " ").toLowerCase() === "x"; - chain() - .command(({ tr }) => { - convertBulletItemToTask(tr, range.from, range.to, checked); - return true; - }) - .run(); - return undefined; - }, -}); - -/** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */ -function taskInputRule(type: NodeType): InputRule { - const rule = wrappingInputRule({ - find: /^- \[([ xX])\] $/, - type, - getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), - }); - return new InputRule({ - find: rule.find, - handler: (props) => - props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null, - }); -} - -function hasAncestor($pos: ResolvedPos, name: string): boolean { - for (let depth = $pos.depth; depth > 0; depth -= 1) { - if ($pos.node(depth).type.name === name) return true; - } - return false; -} - -/** - * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: - * a quote holds prose lines, and a list item is not one. - */ -const blockquoteInputRule = new InputRule({ - find: /^>(\s)$/, - handler: ({ state, range, match, chain }) => { - const $from = state.doc.resolve(range.from); - if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; - chain() - .deleteRange(range) - .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) - .run(); - return undefined; - }, -}); - -/** - * `---` becomes a rule as the third dash lands; `***` and `___` need a space - * after them so typing bold or an underscore is not interrupted, matching - * Tiptap's own rule. Only at a top-level paragraph: the list and quote - * serializers have no line to write a rule into. The typed characters are - * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a - * rule at the end so the caret has somewhere to go. - */ -const horizontalRuleInputRule = new InputRule({ - find: /^(---|\*\*\*|___)\s?$/, - handler: ({ state, range, match, chain }) => { - const source = match[0] ?? "---"; - if (!source.startsWith("---") && !/\s$/.test(source)) return null; - const $from = state.doc.resolve(range.from); - if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; - chain() - .deleteRange(range) - .setHorizontalRule() - .command(({ tr }) => { - // The rule is the block before the caret's paragraph. - const $pos = tr.selection.$from; - const index = $pos.index(0) - 1; - if (index < 0) return true; - const rulePos = $pos.posAtIndex(index, 0); - const rule = tr.doc.nodeAt(rulePos); - if (rule?.type.name === "horizontalRule") { - tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); - } - return true; - }) - .run(); - return undefined; - }, -}); - -/** - * `# ` through `###### ` at a top-level paragraph make a heading. The space - * is required, which is exactly what keeps `#1234` a pull request reference - * with its picker rather than a heading. Not inside lists or quotes, whose - * serializers have no line for one. - */ -const headingInputRule = new InputRule({ - find: /^(#{1,6})(\s)$/, - handler: ({ state, range, match, chain }) => { - const $from = state.doc.resolve(range.from); - if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; - chain() - .deleteRange(range) - .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) - .run(); - return undefined; - }, -}); - -/** Whether the caret sits inside a fenced code block. */ -function isInCodeBlock(view: EditorView): boolean { - return view.state.selection.$from.parent.type.spec.code === true; -} - -const MarkerPluginKey = new PluginKey("composer-rich-markers"); - -const ComposerMarkerPlugin = new Plugin({ - key: MarkerPluginKey, - state: { - init: (_, state) => decorationsForSelection(state), - apply: (tr, old, _, state) => - tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old, - }, - props: { - decorations(state) { - return MarkerPluginKey.getState(state); - }, - }, -}); - -function decorationsForSelection(state: EditorState): DecorationSet { - const { doc, selection } = state; - // Markers at the caret render after it while it types with the marks before - // the edge. Shifting keeps closers ahead of openers at a shared position. - const caretSide = caretTakesMarksBefore(state) ? 3 : 0; - const decorations: Decoration[] = []; - if (!selection.empty) { - doc.nodesBetween(selection.from, selection.to, (node, pos) => { - if (node.type.name.startsWith("composer-")) { - decorations.push( - Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), - ); - return false; - } - return true; - }); - } - for (const range of collectStyledRanges(doc)) { - const active = selection.empty - ? selection.from >= range.from && selection.from <= range.to - : selection.from < range.to && selection.to > range.from; - if (!active) continue; - for (const { at, side: baseSide, text } of range.markers) { - const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide; - const marker = document.createElement("span"); - marker.className = "composer-rich-marker"; - marker.textContent = text; - marker.setAttribute("aria-hidden", "true"); - decorations.push( - Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), - ); - } - } - return DecorationSet.create(doc, decorations); -} - -const ComposerMarkersExtension = Extension.create({ - name: "composer-rich-markers", - addProseMirrorPlugins() { - return [ComposerMarkerPlugin]; - }, -}); - -// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so -// unit tests can round-trip it without a browser. -// ── Editor component ─────────────────────────────────────────────────────── - -type TiptapEditor = NonNullable>; - -export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { - // Extensions are creation-time, so changing text mode remounts the editor. - // Every mode initializes from the controlled value, preserving the draft. - return ( - - ); -} - -/** - * Starts a new undo step when the kind of change switches (typing, deleting, - * a paste or a store rewrite), the way the Lexical composer grouped undo. - * Runs as dispatch middleware because the grouping has to be decided before - * the history plugin applies the transaction. - */ -const ComposerUndoGroupingExtension = Extension.create< - Record, - { previous: ComposerChangeKind | null } ->({ - name: "composer-undo-grouping", - addStorage() { - return { previous: null }; - }, - dispatchTransaction({ transaction, next }) { - this.storage.previous = groupUndoByChangeKind(transaction, this.storage.previous); - next(transaction); - }, -}); - -function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { - const { - value, - cursor, - richTextEnabled, - literalText = false, - contextRecords, - buildContextClipboardFragment, - importContextFragment, - skills, - disabled, - placeholder, - ariaLabel, - suggestionListId, - activeSuggestionId, - isPathQueryActive, - containerClassName, - className, - placeholderClassName, - onChange, - onVisibleSelectionChange, - onCommandKeyDown, - onPageScrollKeyDown, - onPageScrollKeyUp, - onPageScrollRelease, - onCitationSubmitAndSend, - onCitationDraftSave, - onPaste, - editorRef, - } = props; - // The setting toggles styling, not the engine: both modes are Tiptap. - // Plain mode disables the mark extensions, so markers stay literal text. - const richText = !literalText && (richTextEnabled ?? false); - const clampEditorCursor = useCallback( - (text: string, position: number) => - literalText - ? Math.max( - 0, - Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length), - ) - : clampCollapsedComposerCursor(text, position), - [literalText], - ); - const expandEditorCursor = useCallback( - (text: string, position: number) => - literalText ? position : expandCollapsedComposerCursor(text, position), - [literalText], - ); - - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const onCommandKeyDownRef = useRef(onCommandKeyDown); - const isPathQueryActiveRef = useRef(isPathQueryActive); - const buildFragmentRef = useRef(buildContextClipboardFragment); - const importFragmentRef = useRef(importContextFragment); - const skillsRef = useRef(skills); - const latestValueRef = useRef(value); - // The editor instance for callbacks created before it exists (paste). - // Effects flush before any user interaction, so this is always set. - const editorHolder = useRef(null); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - useEffect(() => { - onCommandKeyDownRef.current = onCommandKeyDown; - }, [onCommandKeyDown]); - useEffect(() => { - isPathQueryActiveRef.current = isPathQueryActive; - }, [isPathQueryActive]); - useEffect(() => { - buildFragmentRef.current = buildContextClipboardFragment; - }, [buildContextClipboardFragment]); - useEffect(() => { - importFragmentRef.current = importContextFragment; - }, [importContextFragment]); - useEffect(() => { - skillsRef.current = skills; - }, [skills]); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); - - const skillLabelFor = useCallback((name: string): SkillMeta => { - const normalized = name.startsWith("$") ? name.slice(1) : name; - const skill = skillsRef.current.find((candidate) => candidate.name === normalized); - if (!skill) { - return { label: formatProviderSkillDisplayName({ name: normalized }), description: null }; - } - const shortDescription = skill.shortDescription?.trim(); - return { - label: formatProviderSkillDisplayName(skill), - description: shortDescription || skill.description?.trim() || null, - }; - }, []); - - const initialCursor = clampEditorCursor(value, cursor); - const initialExpandedCursor = expandEditorCursor(value, initialCursor); - const snapshotRef = useRef({ - value, - cursor: initialCursor, - expandedCursor: initialExpandedCursor, - contextIds: literalText ? [] : collectInlineContextIds(value), - }); - const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); - const isApplyingControlledUpdateRef = useRef(false); - const hasAppliedControlledSelectionRef = useRef(false); - const citationRequestRef = useRef(null); - const [openCitation, setOpenCitation] = useState(null); - // The open citation's unsaved comment, saved before a new value replaces the document. - const citationDraftRef = useRef<{ key: string; comment: string } | null>(null); - const [isEmpty, setIsEmpty] = useState(value.length === 0); - - const citationCommentActions = useMemo( - () => ({ - openComment: openCitation, - onOpenChange: (nodeKey: string, open: boolean) => { - if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null; - setOpenCitation((current) => { - if (open) { - if (current?.key === nodeKey) return current; - return { key: nodeKey }; - } - return current?.key === nodeKey ? null : current; - }); - }, - onDraftChange: (nodeKey: string, comment: string) => { - citationDraftRef.current = { key: nodeKey, comment }; - }, - onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), - }), - [onCitationSubmitAndSend, openCitation], - ); - - const handleEditorChange = useCallback( - (updated: TiptapEditor) => { - const map = serializeEditorDoc(updated.state.doc); - // Literal answers hold no chips, so reference-shaped text is not a context. - const contextIds = literalText ? [] : map.contextIds; - const { from, to } = updated.state.selection; - const fromFlat = pmToFlat(map, from); - const toFlat = pmToFlat(map, to); - const nextValue = map.value; - const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)); - const nextExpandedCursor = Math.max( - 0, - Math.min(map.value.length, flatToMarkdown(map, fromFlat)), - ); - const nextSelectionRange = { - start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), - end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), - }; - const previousSelectionRange = selectionRangeRef.current; - selectionRangeRef.current = nextSelectionRange; - setIsEmpty(nextValue.length === 0); - const previousSnapshot = snapshotRef.current; - const snapshotChanged = !( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.contextIds.length === contextIds.length && - previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) - ); - if (isApplyingControlledUpdateRef.current) return; - if (!snapshotChanged) { - if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); - } - return; - } - // A selection-only update while a newer prompt waits to be applied (a chip - // was just inserted through the store) would report stale text and clobber - // the prompt. Let the controlled rewrite land instead. - if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds, - }; - // A fence holds no chips, so nothing in it should summon the mention or - // command menu: `@` in code is a decorator, not a file. Suppressing the - // trigger here also keeps the store from inserting a link the block can - // only show as literal text, which the store would then count as a chip. - const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; - const suppressTrigger = - literalText || - inCodeBlock || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); - }, - [clampEditorCursor, literalText], - ); - - const editorAttributes = useMemo( - () => ({ - class: cn( - "composer-tiptap -m-1 block max-h-52 min-h-19.5 overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", - className, - ), - "data-testid": "composer-editor", - "data-composer-rich-text": richText ? "true" : "false", - role: "textbox", - "aria-multiline": "true", - ...(ariaLabel ? { "aria-label": ariaLabel } : {}), - ...(disabled ? { "aria-readonly": "true" } : {}), - ...(!disabled && suggestionListId - ? { - "aria-autocomplete": "list", - "aria-haspopup": "listbox", - ...(activeSuggestionId - ? { - "aria-controls": suggestionListId, - "aria-activedescendant": activeSuggestionId, - } - : {}), - } - : {}), - "aria-placeholder": placeholder, - }), - [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId], - ); - - const editor = useEditor( - { - extensions: [ - StarterKit.configure({ - blockquote: false, - bulletList: false, - codeBlock: false, - heading: false, - horizontalRule: false, - listItem: false, - link: false, - orderedList: false, - underline: false, - dropcursor: false, - gapcursor: false, - trailingNode: false, - code: false, - // The list extensions handle Backspace and Delete themselves. - listKeymap: false, - undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, - // Plain mode has no marks: typed markers stay literal characters. - ...(richText ? {} : { bold: false, italic: false, strike: false }), - }), - ComposerUndoGroupingExtension, - ComposerMentionExtension, - ComposerSkillExtension, - ComposerCitationExtension, - ComposerContextReferenceExtension, - ComposerMarkersExtension, - ...(richText - ? [ - ComposerCodeExtension, - ComposerCodeBlockExtension.extend({ - addNodeView() { - return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { - // The header holds the language picker. Tiptap only shields - // events aimed at the button element itself, so a click on - // its icon or label would reach ProseMirror and move the - // selection; keep every header event away from the editor. - stopEvent: ({ event }) => - event.target instanceof Element && - event.target.closest(".chat-markdown-codeblock-header") !== null, - }); - }, - // Tiptap's own ``` + space rule would open a fence inside a - // list item or quote, where the serializer has no line for - // it. Enter on a fence line covers the gesture at top level. - addInputRules() { - return []; - }, - }), - composerCodeBlockHighlight({ - resolveTheme: () => - resolveDiffThemeName( - document.documentElement.classList.contains("dark") ? "dark" : "light", - ), - }), - ...ComposerBlockExtensions.map((extension) => - extension.name === "blockquote" - ? extension.extend({ - addInputRules() { - return [blockquoteInputRule]; - }, - }) - : extension.name === "horizontalRule" - ? extension.extend({ - addInputRules() { - return [horizontalRuleInputRule]; - }, - }) - : extension.name === "heading" - ? extension.extend({ - addInputRules() { - return [headingInputRule]; - }, - }) - : extension, - ), - ...ComposerListExtensions.map((extension) => - extension.name === "listItem" - ? extension - : extension.extend({ - addInputRules() { - return this.name === "bulletList" - ? [ - listMarkerInputRule(/^(?[*+])(?\s)$/, "bulletList"), - listMarkerInputRule( - /^(?-)(?[ \t]+)(?[^\s[])$/, - "bulletList", - ), - ] - : [ - listMarkerInputRule( - /^(?\d+[.)])(?\s)$/, - "orderedList", - ), - ]; - }, - }), - ), - ComposerTaskListExtension, - ComposerTaskItemExtension.extend({ - addInputRules() { - return [taskInputRule(this.type), bulletToTaskInputRule]; - }, - }), - ] - : []), - ], - content: buildDocJson( - value, - (name) => { - const normalized = name.startsWith("$") ? name.slice(1) : name; - const found = skills.find((candidate) => candidate.name === normalized); - if (!found) { - return { - label: formatProviderSkillDisplayName({ name: normalized }), - description: null, - }; - } - const shortDescription = found.shortDescription?.trim(); - return { - label: formatProviderSkillDisplayName(found), - description: shortDescription || found.description?.trim() || null, - }; - }, - { styling: richText, literalText }, - ), - editable: !disabled, - editorProps: { - attributes: editorAttributes, - handleKeyDown: (view, event) => { - if ( - isMacPlatform(navigator.platform) && - (event.key === "Home" || event.key === "End") && - !event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.isComposing - ) { - const selection = window.getSelection(); - if ( - selection?.anchorNode && - view.dom.contains(selection.anchorNode) && - typeof selection.modify === "function" - ) { - event.preventDefault(); - event.stopPropagation(); - selection.modify( - event.shiftKey ? "extend" : "move", - event.key === "Home" ? "backward" : "forward", - "lineboundary", - ); - if (selection.anchorNode && selection.focusNode) { - view.dispatch( - view.state.tr - .setSelection( - TextSelection.create( - view.state.doc, - view.posAtDOM(selection.anchorNode, selection.anchorOffset), - view.posAtDOM(selection.focusNode, selection.focusOffset), - ), - ) - .scrollIntoView(), - ); - } - return true; - } - } - if ( - ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) && - !event.shiftKey && - !event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.isComposing && - view.state.selection.empty - ) { - const { $from } = view.state.selection; - const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1; - const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; - // At the document's ends, native Up/Down can cross a marker without - // changing the document position. Record the stop so DOM selection - // synchronization does not put the caret back inside the styling. - const atDocumentEdge = - $from.pos === - (direction === -1 - ? TextSelection.atStart(view.state.doc).from - : TextSelection.atEnd(view.state.doc).to); - // Take the other stop of a styled edge before skipping a chip, so - // the plain stop between styled text and a chip stays reachable. - const step = - horizontal || atDocumentEdge - ? stepCaretAcrossStyledEdge(view.state, direction) - : null; - if (step) { - event.preventDefault(); - event.stopPropagation(); - view.dispatch(step); - return true; - } - const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; - if (horizontal && adjacent?.type.name.startsWith("composer-")) { - event.preventDefault(); - event.stopPropagation(); - view.dispatch( - view.state.tr - .setSelection( - TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize), - ) - .scrollIntoView(), - ); - return true; - } - } - // Shift+Tab from just after a citation reaches its comment button, which - // native tab order skips because the chip lives inside the editor. - if ( - event.key === "Tab" && - event.shiftKey && - !event.altKey && - !event.metaKey && - !event.ctrlKey && - view.state.selection.empty - ) { - const { $from } = view.state.selection; - const citation = $from.nodeBefore; - if (citation?.type.name === "composer-citation") { - const chip = view.nodeDOM($from.pos - citation.nodeSize); - const commentButton = - chip instanceof HTMLElement - ? chip.querySelector("[data-citation-comment-trigger]") - : null; - if (commentButton) { - event.preventDefault(); - event.stopPropagation(); - commentButton.focus(); - return true; - } - } - } - if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { - event.stopPropagation(); - return true; - } - // Enter on a focused task checkbox must not send the prompt (or - // split anything): Space toggles it, Enter does nothing. - if (event.key === "Enter" && event.target instanceof HTMLInputElement) { - event.preventDefault(); - return true; - } - // Inside a fence Tab belongs to the code, not to the composer's - // focus order or its autocomplete. - if ( - event.key === "Tab" && - !event.metaKey && - !event.ctrlKey && - selectionInOneCodeBlock(view.state) - ) { - event.preventDefault(); - event.stopPropagation(); - return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => - view.dispatch(tr), - ); - } - // A fence is multi-line by definition, so Enter and Shift+Enter - // belong to the code rather than to sending or splitting: inside a - // block they make a line (a generic split would cut the fence in - // two), and on a line that is only an opening fence they open the - // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which - // falls through. - if ( - event.key === "Enter" && - richText && - !event.metaKey && - !event.ctrlKey && - !event.isComposing - ) { - if (selectionInOneCodeBlock(view.state)) { - event.preventDefault(); - event.stopPropagation(); - const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); - return ( - exitCodeBlockOnClosingFence(view.state, dispatch) || - exitCodeBlockOnTrailingBlankLines(view) || - indentedNewlineInCodeBlock(view.state, dispatch) || - newlineInCode(view.state, dispatch) - ); - } - if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { - event.preventDefault(); - event.stopPropagation(); - return true; - } - } - const handler = onCommandKeyDownRef.current; - if (event.key === "Enter") { - const instance = editorHolder.current; - const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); - const isListItem = richText && (instance?.isActive("listItem") ?? false); - const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; - if (handled) { - event.preventDefault(); - event.stopPropagation(); - return true; - } - event.preventDefault(); - if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { - return true; - } - if ( - richText && - instance && - hasAncestor(view.state.selection.$from, "blockquote") && - view.state.selection.$from.parent.content.size === 0 && - instance.commands.lift("blockquote") - ) { - return true; - } - // Split the paragraph so a single newline visibly advances the caret. - return splitBlockKeepMarks(view.state, (tr) => { - // The split is programmatic, so the browser won't follow the - // caret into view on its own. - view.dispatch(tr.scrollIntoView()); - }); - } - if (!handler) return false; - const handled = handler(event.key, event); - if (handled) { - event.preventDefault(); - event.stopPropagation(); - } - return handled; - }, - handleTextInput: (view, from, to, text) => { - if (literalText || text.length !== 1) return false; - const closer = SURROUND_CLOSE[text]; - if (!closer || from === to) return false; - // Never wrap chips or other atoms, and never wrap styled text: the - // default replace keeps marks intact, wrapping would drop them. - let touchesSpecial = false; - view.state.doc.nodesBetween(from, to, (node) => { - if ( - (node.isAtom && node.isInline && !node.isText) || - (node.isText && node.marks.length > 0) - ) { - touchesSpecial = true; - return false; - } - return true; - }); - if (touchesSpecial) return false; - const map = serializeEditorDoc(view.state.doc); - const startMd = flatToMarkdown(map, pmToFlat(map, from)); - const endMd = flatToMarkdown(map, pmToFlat(map, to)); - if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; - const tr = view.state.tr.insertText(closer, to).insertText(text, from); - tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); - view.dispatch(tr); - return true; - }, - handlePaste: (view, event) => { - const clipboardData = event.clipboardData; - if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); - if (!pastedText) return false; - event.preventDefault(); - // A fence takes the clipboard verbatim. Running the markdown path - // here would split the block on newlines, nest a pasted fence and - // build chips the code block's schema cannot hold anyway. - if (isInCodeBlock(view)) { - const { from, to } = view.state.selection; - view.dispatch( - markAsClipboardEdit( - view.state.tr.insertText(pastedText, from, to), - "paste", - ).scrollIntoView(), - ); - return true; - } - const importFragment = importFragmentRef.current; - let text = - !literalText && importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; - // Complete chips at paste boundaries just as autocomplete does. - const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); - const lastToken = tokens.at(-1); - if ( - (lastToken?.type === "mention" || lastToken?.type === "skill") && - lastToken.end === text.length - ) { - text += " "; - } - if ( - (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && - tokens[0].start === 0 - ) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); - if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; - } - const { $from } = view.state.selection; - const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText - ? 0 - : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); - const editorInstance = editorHolder.current; - if (editorInstance) { - // Inside a list item or quote, pasted block markup has nowhere to - // go: it stays literal lines the next rebuild reads back. Inline - // marks still apply. - const $paste = view.state.selection.$from; - const nested = ["listItem", "taskItem", "blockquote"].some((name) => - hasAncestor($paste, name), - ); - insertMarkdownParagraphs( - text, - skillLabelFor, - { styling: richText, blocks: !nested, literalText, literalLength }, - (content) => { - // Tagged on the same transaction insertContent builds, so the - // paste is one undo step of its own. - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - .insertContent(content) - .command(({ tr }) => { - // A paste ending in a rule leaves the rule selected, and - // the next keystroke would replace it. The caret goes to - // the line after it, which is added when there is none. - const { selection } = tr; - if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { - return true; - } - if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { - tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); - } - tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); - return true; - }) - .run(); - }, - ); - scrollTiptapCaretIntoView(editorInstance); - } - return true; - }, - }, - onUpdate: ({ editor: updated }) => { - handleEditorChange(updated); - }, - onSelectionUpdate: ({ editor: updated }) => { - handleEditorChange(updated); - }, - }, - [], - ); - - useEffect(() => { - editor?.setEditable(!disabled); - }, [disabled, editor]); - - useEffect(() => { - editorHolder.current = editor; - }, [editor]); - - // Tiptap forwards option changes to the view from a passive effect, so a - // class change here would reach the ProseMirror element one tick after - // React commits. The chat composer measures its resting and expanded - // geometry in layout effects that run first, and it clamps the prompt - // through `className`, so the attributes are pushed to the view here for - // those measurements to see the layout they are about to reserve for. - useLayoutEffect(() => { - if (!editor?.isInitialized) return; - editor.view.setProps({ attributes: editorAttributes }); - }, [editor, editorAttributes]); - - const readSnapshot = useCallback(() => { - const snapshot = snapshotRef.current; - if (!editor) return snapshot; - const map = serializeEditorDoc(editor.state.doc); - const { from, to } = editor.state.selection; - const fromFlat = pmToFlat(map, from); - const next: typeof snapshot = { - value: map.value, - cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)), - expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), - contextIds: literalText ? [] : map.contextIds, - }; - const toFlat = pmToFlat(map, to); - selectionRangeRef.current = { - start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)), - end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)), - }; - snapshotRef.current = next; - return next; - }, [clampEditorCursor, editor, literalText]); - - // A question or approval swaps the controlled value while a citation comment - // is open. Its node view unmounts with the old document, so save the comment - // the way dismissing the popover would, and hand the saved value back. - const saveCitationDraft = useEffectEvent((current: TiptapEditor, replacedValue: string) => { - const draft = citationDraftRef.current; - citationDraftRef.current = null; - if (!draft || !onCitationDraftSave) return; - const [target] = findChildren( - current.state.doc, - (node) => node.type.name === "composer-citation" && node.attrs.citeKey === draft.key, - ); - if (!target) return; - const { node, pos } = target; - const citation = node.attrs.citation as AssistantCitation; - const dismissal = resolveAssistantCitationCommentDismissal({ - reason: "none", - draft: draft.comment, - savedComment: citation.comment, - }); - if (dismissal.kind !== "commit") return; - const next = withAssistantCitationComment(citation, dismissal.comment); - current.view.dispatch( - current.state.tr - .setNodeMarkup(pos, undefined, { - ...node.attrs, - citation: next, - source: serializeAssistantCitation(next), - }) - .setMeta("addToHistory", false), - ); - onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value); - }); - - // Controlled value/cursor from the store (history recall, chip insertion…). - useLayoutEffect(() => { - if (!editor) return; - const initialSelection = !hasAppliedControlledSelectionRef.current; - hasAppliedControlledSelectionRef.current = true; - const normalizedCursor = clampEditorCursor(value, cursor); - const previousSnapshot = snapshotRef.current; - if ( - !initialSelection && - previousSnapshot.value === value && - previousSnapshot.cursor === normalizedCursor - ) { - return; - } - const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor); - snapshotRef.current = { - value, - cursor: normalizedCursor, - expandedCursor: normalizedExpandedCursor, - contextIds: collectInlineContextIds(value), - }; - selectionRangeRef.current = { - start: normalizedExpandedCursor, - end: normalizedExpandedCursor, - }; - setIsEmpty(value.length === 0); - const rootElement = editor.view.dom; - const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (!initialSelection && previousSnapshot.value === value && !isFocused) return; - - isApplyingControlledUpdateRef.current = true; - const pendingCitation = - citationRequestRef.current?.value === value ? citationRequestRef.current : null; - if (previousSnapshot.value !== value) { - saveCitationDraft(editor, previousSnapshot.value); - editor.commands.setContent( - buildDocJson(value, skillLabelFor, { styling: richText, literalText }), - { - emitUpdate: false, - }, - ); - } - const map = serializeEditorDoc(editor.state.doc); - const flat = collapsedToFlat(map, normalizedCursor); - editor.commands.setTextSelection(flatToPm(map, flat)); - if (isFocused) scrollTiptapCaretIntoView(editor); - if (pendingCitation) { - citationRequestRef.current = null; - const target = map.runs.find( - (run) => - run.kind === "token" && - run.nodeName === "composer-citation" && - run.mdStart === pendingCitation.citationStart, - ); - if (target) { - const node = editor.state.doc.nodeAt(target.pmPos); - const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; - if (citeKey) { - setOpenCitation({ - key: citeKey, - sourceAnchor: pendingCitation.sourceAnchor, - removeOnCancel: pendingCitation.insertedSpaces, - }); - } - } - } - queueMicrotask(() => { - isApplyingControlledUpdateRef.current = false; - }); - }, [ - clampEditorCursor, - cursor, - editor, - expandEditorCursor, - literalText, - richText, - skillLabelFor, - value, - ]); - - const focusAt = useCallback( - (nextCursor: number) => { - if (!editor) return; - // view.focus() writes the state selection to the DOM as it focuses. A - // bare DOM focus leaves the native caret at the start until - // ProseMirror resyncs it 20ms later, and dictation tools type fast - // enough to land keys there, ahead of the text already inserted. - editor.view.focus(); - // A newer prompt is waiting to be applied (a chip was just inserted - // through the store). Reporting the editor's stale text now would - // overwrite that prompt; the pending rewrite places the caret instead. - if (snapshotRef.current.value !== latestValueRef.current) return; - const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); - const map = serializeEditorDoc(editor.state.doc); - const flat = collapsedToFlat(map, boundedCursor); - editor.commands.setTextSelection(flatToPm(map, flat)); - scrollTiptapCaretIntoView(editor); - if (boundedCursor === snapshotRef.current.cursor) return; - snapshotRef.current = { - value: snapshotRef.current.value, - cursor: boundedCursor, - expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor), - contextIds: snapshotRef.current.contextIds, - }; - selectionRangeRef.current = { - start: snapshotRef.current.expandedCursor, - end: snapshotRef.current.expandedCursor, - }; - onChangeRef.current( - snapshotRef.current.value, - boundedCursor, - snapshotRef.current.expandedCursor, - literalText, - snapshotRef.current.contextIds, - ); - }, - [clampEditorCursor, editor, expandEditorCursor, literalText], - ); - - useImperativeHandle( - editorRef, - () => ({ - focus: () => { - focusAt(snapshotRef.current.cursor); - }, - focusAt, - focusAtEnd: () => { - focusAt( - literalText - ? snapshotRef.current.value.length - : collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); - }, - readSelectionRange: () => { - readSnapshot(); - return selectionRangeRef.current; - }, - requestCitationComment: (request) => { - citationRequestRef.current = request; - if (!editor) return; - const map = serializeEditorDoc(editor.state.doc); - if (map.value !== request.value) return; - const target = map.runs.find( - (run) => - run.kind === "token" && - run.nodeName === "composer-citation" && - run.mdStart === request.citationStart, - ); - if (!target) return; - const node = editor.state.doc.nodeAt(target.pmPos); - const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; - if (citeKey) { - citationRequestRef.current = null; - setOpenCitation({ - key: citeKey, - sourceAnchor: request.sourceAnchor, - removeOnCancel: request.insertedSpaces, - }); - } - }, - readSnapshot, - isCaretOnVisualEdge: (edge) => { - const snapshot = readSnapshot(); - if (snapshot.value.length === 0) return true; - const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); - const afterCaret = snapshot.value.slice(snapshot.expandedCursor); - if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { - return false; - } - const rootElement = editor?.view.dom; - const selection = window.getSelection(); - if ( - !rootElement || - !selection || - !selection.isCollapsed || - selection.rangeCount === 0 || - !selection.anchorNode || - !rootElement.contains(selection.anchorNode) - ) { - return false; - } - const caretRect = caretLineRect(selection.getRangeAt(0), edge); - if (!caretRect) return false; - const edgeElement = - edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; - const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); - const threshold = caretRect.height / 2; - return edge === "start" - ? caretRect.top - edgeRect.top < threshold - : edgeRect.bottom - caretRect.bottom < threshold; - }, - }), - [editor, focusAt, literalText, readSnapshot], - ); - - const handleCopyCut = useCallback( - (event: React.ClipboardEvent, cut: boolean) => { - const build = buildFragmentRef.current; - if (!editor || (cut && !editor.isEditable)) return; - const clipboardData = event.clipboardData; - const { from, to } = editor.state.selection; - if (from === to) return; - const text = serializeSelection(editor.state.doc, from, to); - const contextIds = literalText ? [] : Array.from(new Set(collectInlineContextIds(text))); - const fragment = contextIds.length > 0 ? build?.(contextIds) : null; - event.preventDefault(); - clipboardData.setData("text/plain", text); - if (fragment) { - clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); - clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); - } - if (cut) { - editor - .chain() - .focus() - .command(({ tr }) => { - markAsClipboardEdit(tr, "cut"); - return true; - }) - .deleteSelection() - .run(); - } - }, - [editor, literalText], - ); - - return ( - - - -
- { - if ( - event.key === "Control" || - event.key === "Meta" || - event.key === "Alt" || - event.key === "Shift" - ) { - onPageScrollRelease?.(); - } - if (event.key !== "PageUp" && event.key !== "PageDown") return; - const target = event.currentTarget.querySelector( - '[data-testid="composer-editor"]', - ) as HTMLElement | null; - if (!target) return; - const pageScrollKey = getTimelinePageScrollKey({ - altKey: event.altKey, - clientHeight: target.clientHeight, - ctrlKey: event.ctrlKey, - defaultPrevented: event.defaultPrevented, - isComposing: event.nativeEvent.isComposing, - key: event.key, - keyCode: event.keyCode, - metaKey: event.metaKey, - scrollHeight: target.scrollHeight, - scrollTop: target.scrollTop, - shiftKey: event.shiftKey, - }); - if (!pageScrollKey) { - onPageScrollRelease?.(); - return; - } - if (!onPageScrollKeyDown) return; - event.preventDefault(); - onPageScrollKeyDown(pageScrollKey); - }} - onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} - onBlur={onPageScrollRelease} - onPasteCapture={onPaste} - onCopyCapture={(event) => handleCopyCut(event, false)} - onCutCapture={(event) => handleCopyCut(event, true)} - /> - {isEmpty && contextRecords.size === 0 && placeholder ? ( -
- {placeholder} -
- ) : null} -
-
-
-
- ); -} - -/** - * Insert pasted markdown at the selection, rebuilding inline tokens as chips - * and styled spans as marks. - * - * Newlines always become paragraph splits — never trailing hard breaks, which - * render no visible line — so pasted text lands exactly as typed. - */ -function insertMarkdownParagraphs( - value: string, - skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, - insertContent: (content: JSONContent[] | JSONContent) => void, -): void { - const blocks = buildTiptapContent(value, skillLabelFor, options); - if (blocks.length === 1 && blocks[0]?.type === "paragraph") { - const inline = (blocks[0]?.content ?? []) as JSONContent[]; - if (inline.length === 0) return; - insertContent(inline); - return; - } - insertContent(blocks as JSONContent[]); -} - -/** - * Follow a programmatically placed caret: native scrolling only happens for - * real input, so controlled rewrites, pastes, and focus restores scroll the - * composer to the caret explicitly. - */ -function scrollTiptapCaretIntoView(editor: TiptapEditor): void { - editor.view.dispatch(editor.state.tr.scrollIntoView()); -} - -/** - * Client rect of the caret's visual line, so prompt history keeps claiming - * ArrowUp/Down only at the first and last soft-wrapped lines. - */ -function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { - const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); - const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; - if (collapsedRect) return collapsedRect; - - const container = range.startContainer; - // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's). - if (container.nodeType === 3) { - const textNode = container as Text; - if (textNode.data.length === 0) return null; - const probeStart = Math.max( - 0, - Math.min( - edge === "start" ? range.startOffset : range.startOffset - 1, - textNode.data.length - 1, - ), - ); - const probeRange = document.createRange(); - probeRange.setStart(textNode, probeStart); - probeRange.setEnd(textNode, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - const boundingRect = probeRange.getBoundingClientRect(); - return boundingRect.height > 0 ? boundingRect : null; - } - - if (!(container instanceof HTMLElement)) return null; - const neighbour = - container.childNodes[Math.max(0, range.startOffset - 1)] ?? - container.childNodes[range.startOffset]; - if (neighbour instanceof HTMLElement) { - const neighbourRect = neighbour.getBoundingClientRect(); - if (neighbourRect.height > 0) return neighbourRect; - } else if (neighbour instanceof Text && neighbour.data.length > 0) { - const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; - const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; - const probeRange = document.createRange(); - probeRange.setStart(neighbour, probeStart); - probeRange.setEnd(neighbour, probeStart + 1); - const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); - if (probeRect) return probeRect; - } - const containerRect = container.getBoundingClientRect(); - return containerRect.height > 0 ? containerRect : null; -} diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 809443c8f6861f95d0516f905a04f413177dab3f..550e6fc57d62621c3caacc7c4ec88840332d9bc4 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -8,7 +8,11 @@ import { type CSSProperties, } from "react"; import { ChatCanvasContext } from "./ChatCanvasContext"; -import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; +import { + DETAILS_CARD_CLEARANCE, + resolveChatCanvasLayout, + type ChatCanvasPreview, +} from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; import { observeResize } from "../../lib/observeResize"; @@ -144,6 +148,15 @@ export function ChatCanvas({ detailsCardTopInset, ]); const { layout } = context; + const blockOutsetStart = Math.max(0, layout.chat.left - measurements.padding); + const blockRight = detailsCard + ? detailsCard.left - DETAILS_CARD_CLEARANCE + : measurements.width - measurements.padding; + const blockOutsetEnd = + layout.chat.insetEnd > 0 + ? 0 + : Math.max(0, Math.min(blockOutsetStart, blockRight - layout.chat.left - layout.chat.width)); + return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 05a5e6702092f9a61aded46fb99db4c73b97d632..99588f08004d3d2d39586d2f438689c5006659c2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -94,7 +94,6 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -293,7 +292,7 @@ import { ComposerControlSeparator, ComposerSelectControl, } from "./ComposerControl"; -import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; import { matchesPullRequestQuery, @@ -1707,7 +1706,7 @@ export interface ChatComposerProps { value: string, nextCursor: number, expandedCursor: number, - cursorAdjacentToMention: boolean, + suppressSuggestions: boolean, ) => void; onProviderModelSelect: ( @@ -2617,6 +2616,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Derived: composer trigger / menu // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; + const [emojiSearch, setEmojiSearch] = useState< + typeof import("./composerEmojiSearch") | "error" | null + >(null); + const isEmojiTrigger = composerTriggerKind === "emoji"; + + useEffect(() => { + if (!isEmojiTrigger || emojiSearch !== null) return; + let active = true; + void import("./composerEmojiSearch").then( + (search) => { + if (active) setEmojiSearch(search); + }, + () => { + if (active) setEmojiSearch("error"); + }, + ); + return () => { + active = false; + }; + }, [emojiSearch, isEmojiTrigger]); + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const pullRequestTriggerQuery = composerTrigger?.kind === "pull-request" ? composerTrigger.query : ""; @@ -2720,6 +2740,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; + if (composerTrigger.kind === "emoji") { + return emojiSearch && emojiSearch !== "error" + ? emojiSearch.searchComposerEmoji(composerTrigger.query) + : []; + } if (composerTrigger.kind === "path") { // Threads only surface for a typed query so `@` alone stays a file picker. A title match // is far more specific than a fuzzy path hit, so the few threads lead the list. @@ -2877,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, compactSlashCommandAvailable, composerTrigger, + emojiSearch, environmentId, environmentThreadShells, exactPullRequestLookup.data, @@ -2964,6 +2990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = + (isEmojiTrigger && emojiSearch === null) || (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || (composerTriggerKind === "pull-request" && pullRequestProjectId !== null && @@ -2973,6 +3000,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestTriggerNumber !== debouncedPullRequestNumber || exactPullRequestLookup.isPending)); const composerMenuEmptyState = useMemo(() => { + if (composerTriggerKind === "emoji") { + return emojiSearch === "error" + ? "Emoji could not load. Reload and try again." + : "No matching emoji."; + } if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } @@ -2996,6 +3028,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ composerTrigger, composerTriggerKind, + emojiSearch, pullRequestLookup.data?.errors, pullRequestLookup.error, pullRequestProjectId, @@ -3827,7 +3860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) nextPrompt: string, nextCursor: number, expandedCursor: number, - cursorAdjacentToMention: boolean, + suppressSuggestions: boolean, contextIds: string[], ) => { expandComposerForEditorChange(); @@ -3835,14 +3868,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, expandedCursor, - cursorAdjacentToMention, + suppressSuggestions, ); return; } @@ -3947,7 +3980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ @@ -4026,7 +4059,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (options?.citationComment) { composerEditorRef.current?.requestCitationComment({ - previousValue: currentText, value: next.text, citationStart: options.citationComment.start, sourceAnchor: options.citationComment.sourceAnchor, @@ -4104,9 +4136,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: isLiteralPendingAnswer - ? null - : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), + trigger: + isLiteralPendingAnswer || composerEditorRef.current?.suggestionsSuppressed() + ? null + : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), }; }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); @@ -4120,6 +4153,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; + if (item.type === "emoji") { + if ( + trigger.kind !== "emoji" || + trigger.query.toLowerCase() !== item.query || + !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) + ) { + return; + } + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, item.emoji, { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) setComposerHighlightedItemId(null); + return; + } if (item.type === "path") { const replacement = `${serializeComposerFileLink(item.path)} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( @@ -4596,7 +4643,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: command key // ------------------------------------------------------------------ - const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => { + const onComposerCommandKey = (key: string, event: KeyboardEvent) => { + if (event.isComposing || event.keyCode === 229) return false; + const { trigger } = resolveActiveComposerTrigger(); + if (trigger?.kind === "emoji") { + const action = composerEmojiKeyAction(event); + if (action === "select") { + const selectedItem = activeComposerMenuItemRef.current; + if (selectedItem?.type === "emoji") onSelectComposerItem(selectedItem); + } else if (action === "next" || action === "previous") { + nudgeComposerMenuHighlight(action === "next" ? "ArrowDown" : "ArrowUp"); + } else if (action === "dismiss") { + dismissComposerTrigger(trigger); + composerMenuOpenRef.current = false; + } + if (action !== null) return true; + } const submissionIntent = composerSubmissionIntentForKey({ event, keybindings, @@ -4611,16 +4673,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode(); return true; } - const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { - if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; + if (!menuIsActive) return false; dismissComposerTrigger(trigger); composerMenuOpenRef.current = false; return true; } - if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { + if ( + menuIsActive && + trigger?.kind !== "emoji" && + (submissionIntent === null || submissionIntent === "foreground") + ) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (key === "ArrowDown" && currentItems.length > 0) { @@ -4651,35 +4716,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return true; } - // Native task splitting preserves marks and chips on both sides of the caret. - if (key === "Enter" && isTaskItem) return false; - if ( - !isLiteralPendingAnswer && - !event.isComposing && - (key === "Enter" || (key === "Tab" && !event.shiftKey)) - ) { - const selection = composerEditorRef.current?.readSelectionRange(); - const snapshot = readComposerSnapshot(); - if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { - const edit = - key === "Enter" - ? listContinuationForEnter(snapshot.value, selection.start) - : listIndentForTab(snapshot.value, selection.start, selection.end); - if ( - edit && - applyPromptReplacement( - edit.start, - edit.end, - edit.replacement, - key === "Tab" - ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } - : undefined, - ) - ) { - return true; - } - } - } return false; }; @@ -6240,11 +6276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // composer is at its attachment limit: `addComposerAttachments` surfaces // those as a toast and a thread error. An early return here would swallow // the paste with no feedback. - if ( - files.length > 0 && - activeThreadId && - shouldHandleComposerAttachmentPaste({ files, plainText }) - ) { + if (files.length > 0 && shouldHandleComposerAttachmentPaste({ files, plainText })) { event.preventDefault(); event.stopPropagation(); void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); @@ -7225,9 +7257,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
{composerSuggestionsVisible && composerMenuItems.length === 0 ? isComposerMenuLoading - ? composerTriggerKind === "pull-request" - ? "Finding pull request..." - : "Searching workspace files..." + ? composerTriggerKind === "emoji" + ? "Loading emoji…" + : composerTriggerKind === "pull-request" + ? "Finding pull request..." + : "Searching workspace files..." : composerMenuEmptyState : ""}
@@ -7657,6 +7691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} - resolveActiveComposerTrigger().trigger?.kind === "path" - } value={ isComposerApprovalState ? "" diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx deleted file mode 100644 index fcaf93af941396851b5c7e2e4ca92be8c8173b63..0000000000000000000000000000000000000000 --- a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; - -import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; - -import { useTheme } from "../../hooks/useTheme"; -import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; -import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; - -/** - * Draws a composer fence in the chat view's own code block frame, so a draft - * looks like the message it is about to become, with the language title - * swapped for a picker that rewrites the fence's language in place. - * - * The chrome stops at the language. Chat's wrap and copy buttons act on text - * the reader cannot change; here the text is the draft, and both are already - * a selection away. - */ -export function ComposerCodeBlockNodeView({ - node, - editor, - getPos, - updateAttributes, -}: NodeViewProps) { - const { resolvedTheme } = useTheme(); - const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; - const language = languageOfInfoString(info); - - const changeLanguage = (next: string) => { - updateAttributes({ language: withInfoStringLanguage(info, next) }); - // Back into the code, at its end, the way a picker hands focus back. - const position = getPos(); - if (typeof position === "number") { - editor - .chain() - .focus(position + 1 + node.content.size) - .run(); - } - }; - - return ( - - } - > -
-
-          {/* A caret needs a line to sit on even before any code is typed. */}
-          
-            as="code"
-            className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]"
-          />
-        
-
-
- ); -} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index e89f4375ef85217849faab386a56554d45e92b96..9768894f29ec8942cbd4a009ed0b6cd8451291d6 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -31,6 +31,14 @@ import { ComposerBanner } from "./ComposerBanner"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; export type ComposerCommandItem = + | { + id: string; + type: "emoji"; + emoji: string; + query: string; + label: string; + description: string; + } | { id: string; type: "path"; @@ -138,11 +146,13 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

{props.isLoading - ? props.triggerKind === "skill" - ? "Searching workspace skills..." - : props.triggerKind === "pull-request" - ? "Finding pull request..." - : "Searching workspace files..." + ? props.triggerKind === "emoji" + ? "Loading emoji…" + : props.triggerKind === "skill" + ? "Searching workspace skills..." + : props.triggerKind === "pull-request" + ? "Finding pull request..." + : "Searching workspace files..." : (props.emptyStateText ?? (props.triggerKind === "skill" ? "No skills found. Try / to browse provider commands." @@ -190,6 +200,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { props.onSelect(props.item); }} > + {props.item.type === "emoji" ? ( + + ) : null} {props.item.type === "path" ? ( = { "pull-request": "Pull requests", "slash-command": "Commands", skill: "Skills", + emoji: "Emoji", }; const SKILL_SOURCE_ICON_BY_KIND: Record = { diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 98c73d47c97a05be84c6dbe3bf9af9e5a20b4a53..affd8fa3c2cc8ec798a1a49d3c4a31136fd78d58 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -65,6 +65,7 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl" stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} onRetry={asset ? refreshAssetUrl : undefined} + actionsSource={item.actionsSource} /> ); } diff --git a/apps/web/src/components/chat/FileTagChip.tsx b/apps/web/src/components/chat/FileTagChip.tsx index b6235dde889139524ae22e33c6cb7c729d189c8d..00b05ca1c1a86f096a66e3662a05800d97535b7b 100644 --- a/apps/web/src/components/chat/FileTagChip.tsx +++ b/apps/web/src/components/chat/FileTagChip.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { ContextChipLabel } from "../ContextChip"; import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; @@ -5,7 +6,7 @@ import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; /** Icon and label for a file mention; render inside ``. */ export function FileTagChipContent(props: { path: string; - label: string; + label: ReactNode; theme: "light" | "dark"; environmentId?: EnvironmentId | null | undefined; cwd?: string | undefined; diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx index 5133c0ea55cd5b47272c545495d02b42a84c9791..8019d6af134f49856a86d0f57a392c103b5baccb 100644 --- a/apps/web/src/components/chat/HtmlRenderFrame.tsx +++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx @@ -3,6 +3,7 @@ import { HTML_RENDER_COLUMN_WIDTH, htmlRenderFileName, htmlRenderFrameHeight, + readHtmlRenderContentSize, type HtmlRenderReference, } from "@t3tools/shared/htmlRender"; import { Maximize2Icon } from "lucide-react"; @@ -47,8 +48,8 @@ export function HtmlRenderFrame(props: { }, []); // Client fonts can wrap a page taller than the server measured it; a frame // left short would scroll inside the thread and take the reader's scroll. - const [contentHeight, setContentHeight] = useState(); - const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight); + const [contentSize, setContentSize] = useState>(); + const height = htmlRenderFrameHeight(props.htmlRender, width, contentSize?.height); const fileName = htmlRenderFileName(title); const resource = useMemo( () => ({ @@ -100,7 +101,11 @@ export function HtmlRenderFrame(props: { }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]); return ( -

+
{src !== null ? ( <>
diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx index dd03bdc4ea108048016ee80ecba3a45e48dfea6f..d7e36dac8f0454f6a8aa7404adb658d68c104f1d 100644 --- a/apps/web/src/components/chat/MermaidDiagram.tsx +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -47,13 +47,20 @@ function sanitizeMermaidSvg(svg: string): string { data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none"); }); } - return purifier.sanitize(svg, { + const sanitized = purifier.sanitize(svg, { ADD_TAGS: ["foreignObject"], HTML_INTEGRATION_POINTS: { foreignobject: true }, FORBID_ATTR: ["href", "xlink:href", "src", "srcset"], FORBID_TAGS: ["a", "img", "image", "script"], USE_PROFILES: { svg: true, svgFilters: true, html: true }, }); + const element = new DOMParser().parseFromString(sanitized, "image/svg+xml").documentElement; + const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number); + if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) { + element.setAttribute("width", String(viewBox[2])); + element.setAttribute("height", String(viewBox[3])); + } + return new XMLSerializer().serializeToString(element); } // Mermaid also lazy-loads diagram chunks inside render(); losing the network @@ -145,11 +152,6 @@ let expandedImageUrl: string | null = null; function mermaidImageUrl(svg: string): string { const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); const element = svgDocument.documentElement; - const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number); - if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) { - element.setAttribute("width", String(viewBox[2])); - element.setAttribute("height", String(viewBox[3])); - } element.setAttribute("xmlns", "http://www.w3.org/2000/svg"); element.style.maxWidth = "none"; element.style.backgroundColor = getComputedStyle(document.body).backgroundColor; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e8cfadc74b60de79a4192d6851d572adeaab61fa..7c33032f909d02366ee1454ff1fa30d035627cfb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1582,7 +1582,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const renderItem = useCallback( ({ item }: { item: MessagesTimelineRow }) => (
-
+
@@ -2836,7 +2836,7 @@ function AssistantTimelineRow({ row }: { row: Extract -
+
The Knife
+
) : null @@ -5488,6 +5491,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowP function WorkEntryLogRow(props: WorkEntryRowProps) { const { workEntry, workspaceRoot, displayLabel } = props; const ctx = use(TimelineRowCtx); + const { isWorking, latestRunId } = use(TimelineRowActivityCtx); const { threadRef, onImageExpand, timestampFormat } = ctx; const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId); @@ -5582,6 +5586,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { !showWarningIndicator && showFailedIndicator && (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); + const active = + isWorking && + workEntry.runId === latestRunId && + workEntry.toolLifecycleStatus === "inProgress" && + !showFailedIndicator; const entryIconName = showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry); const entryToolIcon = @@ -5675,17 +5684,21 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? "text-destructive" : showFailedIndicator ? failedToolIconClassName - : workEntry.tone === "tool" - ? "text-icon-muted" - : iconConfig.className, + : active + ? "text-(--activity-foreground)" + : workEntry.tone === "tool" + ? "text-icon-muted" + : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : workLogEntryIsToolLike(workEntry) - ? "text-secondary-label" - : "text-foreground/80"; + : active + ? "text-(--activity-foreground)" + : workLogEntryIsToolLike(workEntry) + ? "text-secondary-label" + : "text-foreground/80"; const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget); const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview] .filter(Boolean) @@ -5724,7 +5737,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { icon={entryToolIcon} fallbackName={entryIconName} className="block size-4 shrink-0 stroke-2" - muted + muted={!active} /> } diff --git a/apps/web/src/components/chat/composerEmojiSearch.test.ts b/apps/web/src/components/chat/composerEmojiSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3826d04b3667739758330721e98998d0202d5cae --- /dev/null +++ b/apps/web/src/components/chat/composerEmojiSearch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { searchComposerEmoji } from "./composerEmojiSearch"; + +describe("composer emoji search", () => { + it("puts the exact shortcode ahead of longer matching names", () => { + const matches = searchComposerEmoji("dizzy"); + expect(matches[0]).toMatchObject({ type: "emoji", emoji: "💫", label: ":dizzy:" }); + expect(matches.some((entry) => entry.label === ":dizzy_face:")).toBe(true); + }); + + it("finds canonical aliases, tags, descriptions, and multi-codepoint emoji", () => { + expect(searchComposerEmoji("+1")[0]).toMatchObject({ emoji: "👍", label: ":+1:" }); + expect(searchComposerEmoji("happy").length).toBeGreaterThan(0); + expect(searchComposerEmoji("rainbow_flag")[0]).toMatchObject({ emoji: "🏳️‍🌈" }); + expect( + searchComposerEmoji("scientist").some((entry) => entry.label === ":woman_scientist:"), + ).toBe(true); + }); + + it("normalizes uppercase, caps broad searches, and keeps emoji results unique", () => { + const matches = searchComposerEmoji("a"); + expect(matches).toHaveLength(30); + expect(new Set(matches.map((entry) => entry.id)).size).toBe(matches.length); + expect(searchComposerEmoji("DIZZY")).toEqual(searchComposerEmoji("dizzy")); + expect(searchComposerEmoji("")).toEqual([]); + expect(searchComposerEmoji("no_such_emoji_999")).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/composerEmojiSearch.ts b/apps/web/src/components/chat/composerEmojiSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..4820a4bcc7b4c40de7566334998129c27bf97d4b --- /dev/null +++ b/apps/web/src/components/chat/composerEmojiSearch.ts @@ -0,0 +1,40 @@ +import { gemoji } from "gemoji"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +export function searchComposerEmoji(query: string): ComposerCommandItem[] { + const normalized = query.toLowerCase(); + if (!normalized) return []; + + return gemoji + .flatMap((entry) => { + const name = + entry.names.find((name) => name === normalized) ?? + entry.names.find((name) => name.startsWith(normalized)) ?? + entry.names.find((name) => name.includes(normalized)); + const rank = name + ? name === normalized + ? 0 + : name.startsWith(normalized) + ? 1 + : 2 + : entry.tags.some((tag) => tag.includes(normalized)) + ? 3 + : entry.description.toLowerCase().includes(normalized.replaceAll("_", " ")) + ? 4 + : null; + if (rank === null) return []; + + return [{ entry, name: name ?? entry.names[0]!, rank }]; + }) + .toSorted((a, b) => a.rank - b.rank || a.name.localeCompare(b.name)) + .slice(0, 30) + .map(({ entry, name }) => ({ + id: `emoji:${entry.emoji}`, + type: "emoji", + emoji: entry.emoji, + query: normalized, + label: `:${name}:`, + description: entry.description, + })); +} diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts index 74768d906878134e24c8c9684160c3aa947df8a8..d5a52bb10844eb9049aff64f1807408606baf31e 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.test.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; describe("resolveComposerMenuActiveItemId", () => { const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; @@ -66,3 +66,41 @@ describe("resolveComposerMenuActiveItemId", () => { ).toBe("new-result"); }); }); + +describe("emoji autocomplete keys", () => { + const key = (key: string, overrides: Partial = {}) => ({ + key, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + isComposing: false, + keyCode: 0, + ...overrides, + }); + + it.each([ + ["Enter", "select"], + ["Tab", "select"], + ["ArrowDown", "next"], + ["ArrowUp", "previous"], + ["Escape", "dismiss"], + ] as const)("claims %s without allowing submission or moving editor focus", (input, action) => + expect(composerEmojiKeyAction(key(input))).toBe(action), + ); + + it("leaves newline, focus navigation, send chords, and ordinary typing alone", () => { + expect(composerEmojiKeyAction(key("Enter", { shiftKey: true }))).toBeNull(); + expect(composerEmojiKeyAction(key("Tab", { shiftKey: true }))).toBeNull(); + expect(composerEmojiKeyAction(key("Enter", { metaKey: true }))).toBeNull(); + expect(composerEmojiKeyAction(key("Enter", { ctrlKey: true }))).toBeNull(); + expect(composerEmojiKeyAction(key("z"))).toBeNull(); + }); + + it("never selects or dismisses during IME composition", () => { + for (const input of ["Enter", "Tab", "ArrowDown", "ArrowUp", "Escape"]) { + expect(composerEmojiKeyAction(key(input, { isComposing: true }))).toBeNull(); + expect(composerEmojiKeyAction(key(input, { keyCode: 229 }))).toBeNull(); + } + }); +}); diff --git a/apps/web/src/components/chat/composerMenuHighlight.ts b/apps/web/src/components/chat/composerMenuHighlight.ts index 3cc3d4324fc9885d0a1c72be88d36beb44e59ce6..90485cfe7c14ac65005ded7f79dc72917bb768d3 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.ts @@ -18,3 +18,18 @@ export function resolveComposerMenuActiveItemId(input: { return input.items[0]?.id ?? null; } + +export function composerEmojiKeyAction( + event: Pick< + KeyboardEvent, + "key" | "shiftKey" | "altKey" | "metaKey" | "ctrlKey" | "isComposing" | "keyCode" + >, +): "select" | "next" | "previous" | "dismiss" | null { + if (event.isComposing || event.keyCode === 229) return null; + if (event.key === "Escape") return "dismiss"; + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return null; + if (event.key === "Enter" || event.key === "Tab") return "select"; + if (event.key === "ArrowDown") return "next"; + if (event.key === "ArrowUp") return "previous"; + return null; +} diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx index 6daf88bfbd679e16e383eee4d3375fb19fa113bb..f99870cb7e5b09f99d5dfea53cddebf985e6db2f 100644 --- a/apps/web/src/components/chat/useComposerTriggerState.test.tsx +++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx @@ -133,7 +133,7 @@ describe("composer suggestion dismissal", () => { expect(composer.resolveTrigger(candidate)).toEqual(candidate); }); - it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { + it.each(["/plan", "$skill", "#123", ":dizzy"])("also dismisses %s suggestions", async (text) => { await updatePrompt(text); await act(() => composer.dismissTrigger(composer.trigger)); await updatePrompt(`${text}x`); diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts index bd765dbd7b4428025ddee1a59d37feab5062c8a0..4faf1ea5bac83c36da53443a0edcc531141c711d 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.test.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -1,152 +1,100 @@ -import { Editor } from "@tiptap/core"; -import type { EditorView } from "@tiptap/pm/view"; -import StarterKit from "@tiptap/starter-kit"; +// @vitest-environment jsdom + +import { StateEffect } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; -import type { DiffThemeName } from "~/lib/diffRendering"; -import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; +const tokenization = vi.hoisted(() => { + let resolve: (value: { + codeToTokens: (code: string) => { tokens: Array> }; + }) => void; + const ready = new Promise<{ + codeToTokens: (code: string) => { tokens: Array> }; + }>((done) => { + resolve = done; + }); + return { ready, resolve: (value: Parameters[0]) => resolve(value) }; +}); +vi.mock("~/lib/syntaxHighlighting", () => ({ + getSyntaxHighlighterPromise: () => tokenization.ready, +})); -import { - composerCodeBlockHighlight, - MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, - shouldHighlightCodeBlock, - tokenizeBlock, -} from "./composerCodeBlockHighlight"; +import { createComposerMarkdownEditor } from "./composerMarkdownEditor"; +import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; +import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; -vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); - -const THEME = "github-dark" as DiffThemeName; - -/** - * Stands in for the Shiki highlighter so the offset arithmetic can be checked - * without loading a real grammar. Splits each line into whitespace-delimited - * tokens, which is enough shape for the flattening logic under test. - */ -function fakeHighlighter(colorFor: (content: string) => string | undefined) { - return { - codeToTokens(code: string) { - return { - tokens: code.split(/\r?\n/).map((line) => - line - .split(/(\s+)/) - .filter((part) => part.length > 0) - .map((content) => ({ content, color: colorFor(content) })), - ), - }; - }, - } as unknown as Parameters[0]; +let editor: ReturnType; +afterEach(() => { + editor?.destroy(); + editor?.dom.remove(); +}); +function mount(value: string) { + const props: ComposerPromptEditorProps = { + value, + cursor: value.length, + contextRecords: new Map(), + skills: [], + disabled: false, + richTextEnabled: true, + placeholder: "Message", + editorRef: { current: null }, + onChange: () => {}, + onPaste: () => {}, + }; + editor = createComposerMarkdownEditor({ + props: () => props, + mountChip: () => {}, + unmountChip: () => {}, + }); + document.body.append(editor.dom); + return editor; } -describe("composer code block highlighting", () => { - it("maps token offsets to positions within the block text", () => { - const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); - - const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); - - expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); - }); - - it("accounts for the newline between lines", () => { - const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); - - const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); - - // "one" is 3 characters, then the newline, so "two" starts at 4. - expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); - }); - - it("accounts for both characters of a CRLF line break", () => { - const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); - - const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); - - expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); - }); - - it("skips whitespace-only tokens so indentation is not decorated", () => { - const highlighter = fakeHighlighter(() => "#0000ff"); - - const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); - - expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); - }); - - it("falls back to plain text when the grammar throws", () => { - const throwing = { - codeToTokens() { - throw new Error("unsupported language"); - }, - } as unknown as Parameters[0]; - - expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); - }); +it("lands an empty fence's language selection in its body rather than inside the info string", () => { + mount("```ts\n\n```"); + const block = composerCodeBlocks(editor.view.state)[0]!; + expect(editor.view.state.doc.lineAt(block.codeTo).number).toBe(2); + editor.view.dispatch({ + changes: { from: block.infoFrom, to: block.infoTo, insert: "python" }, + selection: { anchor: block.codeTo + 4 }, + }); + expect(editor.getValue()).toBe("```python\n\n```"); + expect(editor.view.state.doc.lineAt(editor.view.state.selection.main.head).number).toBe(2); }); -describe("the highlighting size cap", () => { - // Each keystroke re-tokenizes the whole fence on the main thread; past a - // few hundred lines that stalls typing, so large fences stay plain. - it("highlights a fence up to the cap", () => { - expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); - }); - - it("leaves a fence past the cap plain", () => { - expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); - }); +it("keeps the trailing line of an unfinished fence as part of its code body", () => { + mount("```ts\nunfinished\n"); + const block = composerCodeBlocks(editor.view.state)[0]!; + expect(block.code).toBe("unfinished\n"); + expect(block.codeTo).toBe(editor.view.state.doc.length); }); -describe("a highlighter that fails to load", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - /** - * Shiki can fail to initialize at all. The blocks it could not reach stay - * plain, the ones it could are still painted, and nothing is left rejected. - */ - it("still repaints the blocks whose highlighter loaded", async () => { - vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => - language === "ts" - ? Promise.resolve(fakeHighlighter(() => "#ff0000")) - : Promise.reject(new Error("Shiki failed to initialize")), - ); - const editor = new Editor({ - extensions: [ - StarterKit.configure({ codeBlock: false, trailingNode: false }), - ComposerCodeBlockExtension, - composerCodeBlockHighlight({ resolveTheme: () => THEME }), - ], - content: { - type: "doc", - content: [ - { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, - { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, - ], - }, - }); - // The plugin watches for theme changes; the unit suite has no DOM. - vi.stubGlobal("document", { documentElement: {} }); - vi.stubGlobal( - "MutationObserver", - class { - observe() {} - disconnect() {} - }, - ); - // ProseMirror names each plugin after its key, though it does not type it. - const plugin = editor.extensionManager.plugins.find((candidate) => - (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), - ); - const repainted = new Promise((resolve) => { - const view = { - get state() { - return editor.state; - }, - dispatch: () => resolve(), - } as unknown as EditorView; - plugin?.spec.view?.(view); +describe("asynchronous code highlighting", () => { + it("paints current text after initialization and never reintroduces an edited-away tokenization", async () => { + mount("```ts\nold\n```"); + editor.setValue("```ts\nnew\n```"); + const settled = new Promise((resolve) => { + editor.view.dispatch({ + effects: StateEffect.appendConfig.of( + EditorView.updateListener.of(() => { + if ((editor.view.plugin(composerCodeBlockHighlight)?.decorations.size ?? 0) > 0) + resolve(); + }), + ), + }); }); - - await expect(repainted).resolves.toBeUndefined(); + const codeToTokens = vi.fn((code: string) => ({ + tokens: [[{ content: code, color: "#123456" }]], + })); + tokenization.resolve({ codeToTokens }); + await settled; + expect(codeToTokens.mock.calls.map(([code]) => code)).not.toContain("old"); + expect(codeToTokens.mock.calls.map(([code]) => code)).toContain("new"); + expect(editor.getValue()).toBe("```ts\nnew\n```"); + expect( + [...editor.dom.querySelectorAll("span")].find( + (span) => span.style.color === "rgb(18, 52, 86)", + )?.textContent, + ).toBe("new"); }); }); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts index 68f024cb907a58a72ae60bc4ec846cfde4808bfc..b68921c300426dabac556bd1337cffd9eef433be 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -1,231 +1,155 @@ -import { Extension } from "@tiptap/core"; -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { syntaxTree } from "@codemirror/language"; +import { type EditorState, StateEffect } from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; -import type { DiffThemeName } from "~/lib/diffRendering"; import { languageOfInfoString } from "~/composer-code-languages"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; -interface HighlightedBlock { - /** Guards against repainting a block whose text and language are unchanged. */ - readonly signature: string; - readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; -} - -const composerCodeBlockHighlightKey = new PluginKey("composerCodeBlockHighlight"); - -/** - * Past this many characters a fence is left plain. Any edit changes a block's - * signature, so each keystroke re-tokenizes the whole block synchronously on - * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms - * at a thousand, which stalls typing. 20k characters is roughly 400–500 lines - * of ordinary code; a fence that shrinks back under it is highlighted again. - */ -export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; - -export function shouldHighlightCodeBlock(code: string): boolean { - return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; -} -function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { - // A separator that cannot occur in a language name keeps the parts distinct. - return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); -} - -/** - * The composer's code blocks, which are always top-level: list items and - * quotes cannot hold one. Scanning only the top level keeps a keystroke in a - * long draft from walking every node in it. - */ -function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { - const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; - state.doc.forEach((node, pos) => { - if (node.type.name === "codeBlock") blocks.push({ node, pos }); +export function composerCodeBlocks(state: EditorState) { + const blocks: Array<{ + from: number; + infoFrom: number; + infoTo: number; + info: string; + codeFrom: number; + codeTo: number; + code: string; + }> = []; + syntaxTree(state).iterate({ + enter(node) { + if (node.name !== "FencedCode") return undefined; + const info = node.node.getChild("CodeInfo"); + const mark = node.node.getChild("CodeMark"); + if (!mark) return false; + const code = node.node.getChild("CodeText"); + const openingLine = state.doc.lineAt(node.from); + const bodyFrom = + openingLine.number < state.doc.lines + ? state.doc.line(openingLine.number + 1).from + : openingLine.to; + const codeFrom = code?.from ?? bodyFrom; + const codeTo = + code && + node.node.getChildren("CodeMark").length > 1 && + state.sliceDoc(code.to - 1, code.to) === "\n" + ? Math.max(codeFrom, code.to - 1) + : (code?.to ?? bodyFrom); + blocks.push({ + from: node.from, + infoFrom: info?.from ?? mark.to, + infoTo: info?.to ?? mark.to, + info: info ? state.sliceDoc(info.from, info.to) : "", + codeFrom, + codeTo, + code: state.sliceDoc(codeFrom, codeTo), + }); + return false; + }, }); return blocks; } -/** - * Highlights composer code blocks with the same Shiki instance the chat view - * uses, painted as inline decorations so the text nodes stay editable. - * - * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the - * block that changed, and only after its highlighter has resolved. Blocks whose - * text and language are unchanged reuse their previous decorations. - */ -export function composerCodeBlockHighlight(options: { - resolveTheme: () => DiffThemeName; -}): Extension { - return Extension.create({ - name: "composerCodeBlockHighlight", +export const composerCodeBlockHighlight = ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + readonly cache = new Map(); + readonly repaint = StateEffect.define(); + readonly observer: MutationObserver; + disposed = false; + live = new Set(); + doc: EditorState["doc"] | null = null; + tree: ReturnType | null = null; + theme = ""; - addProseMirrorPlugins() { - // Every keystroke in a code block mints a new signature, so each scan - // drops the signatures the document no longer holds. The cache is then - // bounded by the document itself, and no block it holds is ever evicted. - const cache = new Map(); - // The latest scan's signatures, so a tokenize that resolves after its - // block was edited away does not put it back. - let live = new Set(); - - return [ - new Plugin({ - key: composerCodeBlockHighlightKey, - - state: { - init: () => DecorationSet.empty, - apply(transaction, value, _oldState, newState) { - if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { - return value.map(transaction.mapping, transaction.doc); - } - return buildDecorations(newState, cache, options.resolveTheme()); - }, - }, - - props: { - decorations(state) { - return composerCodeBlockHighlightKey.getState(state); - }, - }, - - view(view) { - let disposed = false; - let paintedTheme = options.resolveTheme(); - let scannedDoc: ProseMirrorNode | null = null; - - /** - * Tokenizes any block missing from the cache, then repaints once. - * Returning early when nothing is pending is what stops the - * dispatch below from re-triggering this on its own update. - */ - const refresh = () => { - const theme = options.resolveTheme(); - const themeChanged = theme !== paintedTheme; - paintedTheme = theme; - // A selection change cannot alter what needs tokenizing, and - // building a signature means concatenating every block's text. - if (!themeChanged && view.state.doc === scannedDoc) return; - scannedDoc = view.state.doc; - const blocks = collectCodeBlocks(view.state); - live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); - for (const signature of cache.keys()) { - if (!live.has(signature)) cache.delete(signature); - } - const pending = blocks.filter( - ({ node }) => - shouldHighlightCodeBlock(node.textContent) && - !cache.has(blockSignature(node, theme)), - ); - if (pending.length === 0) { - // A theme switch keeps every signature but changes which one - // applies, so the painted decorations still have to be rebuilt. - if (themeChanged) { - view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); - } - return; - } - - // Settled rather than all: a block whose highlighter fails to - // load stays plain, and the others are still painted. - void Promise.allSettled( - pending.map(async ({ node }) => { - // The stored info string keeps everything after the language - // (`js title=x`); Shiki wants only the name. - const language = - languageOfInfoString(String(node.attrs.language ?? "")) || "text"; - const signature = blockSignature(node, theme); - const highlighter = await getSyntaxHighlighterPromise(language); - if (disposed || cache.has(signature) || !live.has(signature)) return; - cache.set(signature, { - signature, - decorations: tokenizeBlock(highlighter, node.textContent, language, theme), + constructor(readonly view: EditorView) { + this.observer = new MutationObserver(() => view.dispatch({ effects: this.repaint.of(null) })); + this.observer.observe(document.documentElement, { + attributeFilter: ["class", "data-theme-id"], + }); + this.refresh(); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + syntaxTree(update.state) !== this.tree || + update.transactions.some((transaction) => + transaction.effects.some((effect) => effect.is(this.repaint)), + ) + ) + this.refresh(); + } + refresh() { + const state = this.view.state; + const theme = resolveDiffThemeName( + document.documentElement.classList.contains("dark") ? "dark" : "light", + ); + const blocks = composerCodeBlocks(state); + const signature = (block: (typeof blocks)[number]) => + `${theme}\u0000${block.info}\u0000${block.code}`; + this.live = new Set(blocks.map(signature)); + for (const key of this.cache.keys()) if (!this.live.has(key)) this.cache.delete(key); + this.decorations = Decoration.set( + blocks.flatMap((block) => + (this.cache.get(signature(block)) ?? []).map((span) => + Decoration.mark({ attributes: { style: `color: ${span.color}` } }).range( + block.codeFrom + span.from, + block.codeFrom + span.to, + ), + ), + ), + true, + ); + if (this.doc === state.doc && this.tree === syntaxTree(state) && this.theme === theme) return; + this.doc = state.doc; + this.tree = syntaxTree(state); + this.theme = theme; + const pending = blocks.filter( + (block) => block.code.length <= 20_000 && !this.cache.has(signature(block)), + ); + if (pending.length === 0) return; + void Promise.allSettled( + pending.map(async (block) => { + const key = signature(block); + const language = languageOfInfoString(block.info) || "text"; + const highlighter = await getSyntaxHighlighterPromise(language); + if (this.disposed || this.cache.has(key) || !this.live.has(key)) return; + const spans: Array<{ from: number; to: number; color: string }> = []; + try { + let offset = 0; + for (const [index, line] of highlighter + .codeToTokens(block.code, { lang: language, theme }) + .tokens.entries()) { + if (index > 0) offset += block.code.startsWith("\r\n", offset) ? 2 : 1; + for (const token of line) { + if (token.color && token.content.trim()) + spans.push({ + from: offset, + to: offset + token.content.length, + color: token.color, }); - }), - ).then(() => { - if (disposed) return; - view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); - }); - }; - - refresh(); - // The theme lives on , outside the editor, so nothing would - // otherwise tell the view a repaint is due. - const themeObserver = new MutationObserver(refresh); - themeObserver.observe(document.documentElement, { - attributeFilter: ["class"], - }); - - return { - update: refresh, - destroy() { - disposed = true; - themeObserver.disconnect(); - }, - }; - }, + offset += token.content.length; + } + } + } catch { + /* Unsupported languages retain source text. */ + } + this.cache.set(key, spans); }), - ]; - }, - }); -} - -type Highlighter = Awaited>; - -/** Flattens Shiki's line/token structure into offsets within the block's text. */ -export function tokenizeBlock( - highlighter: Highlighter, - code: string, - language: string, - theme: DiffThemeName, -): ReadonlyArray<{ from: number; to: number; color: string }> { - let tokens; - try { - tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; - } catch { - // An unsupported language should read as plain text, not break the editor. - return []; - } - - const decorations: Array<{ from: number; to: number; color: string }> = []; - let offset = 0; - for (const [lineIndex, line] of tokens.entries()) { - // The separator between lines, which Shiki drops; CRLF is two characters. - if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; - for (const token of line) { - const length = token.content.length; - if (token.color && token.content.trim()) { - decorations.push({ from: offset, to: offset + length, color: token.color }); - } - offset += length; + ).then(() => { + if (!this.disposed) this.view.dispatch({ effects: this.repaint.of(null) }); + }); } - } - return decorations; -} - -function buildDecorations( - state: EditorState, - cache: Map, - theme: DiffThemeName, -): DecorationSet { - const decorations: Decoration[] = []; - for (const { node, pos } of collectCodeBlocks(state)) { - if (!shouldHighlightCodeBlock(node.textContent)) continue; - const highlighted = cache.get(blockSignature(node, theme)); - if (!highlighted) continue; - // Token offsets index the block's text, which lines up with document - // positions only because `codeBlock` is `text*` with no marks — one - // unmarked text node, so `content.size === textContent.length`. Allowing - // marks or hard breaks in the code block would desync these silently. - if (node.content.size !== node.textContent.length) continue; - // +1 steps past the code block's opening token into its text. - const start = pos + 1; - for (const decoration of highlighted.decorations) { - decorations.push( - Decoration.inline(start + decoration.from, start + decoration.to, { - style: `color:${decoration.color}`, - }), - ); + destroy() { + this.disposed = true; + this.observer.disconnect(); } - } - return DecorationSet.create(state.doc, decorations); -} + }, + { decorations: (plugin) => plugin.decorations }, +); diff --git a/apps/web/src/components/composerMarkdownEditor.test.ts b/apps/web/src/components/composerMarkdownEditor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d3aa31842583991a6323a550a2537c2c7aa0162 --- /dev/null +++ b/apps/web/src/components/composerMarkdownEditor.test.ts @@ -0,0 +1,412 @@ +// @vitest-environment jsdom + +import { undo, redo } from "@codemirror/commands"; +import { Transaction } from "@codemirror/state"; +import { ComposerContextId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + encodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { detectComposerTrigger, composerSubmissionIntentForKey } from "~/composer-logic"; +import { composerEmojiKeyAction } from "./chat/composerMenuHighlight"; +import { + composerSuggestionsSuppressed, + composerTokens, + createComposerMarkdownEditor, +} from "./composerMarkdownEditor"; +import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; + +let editor: ReturnType; +let props: ComposerPromptEditorProps; +let sends: ReturnType void>>; + +beforeEach(() => { + Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(), + }); + props = { + value: "", + cursor: 0, + contextRecords: new Map(), + skills: [], + disabled: false, + placeholder: "Message", + editorRef: { current: null }, + onPaste: () => {}, + onChange: (value, cursor) => { + props = { ...props, value, cursor }; + }, + }; + sends = vi.fn<(intent: string) => void>(); +}); +afterEach(() => { + editor?.destroy(); + editor?.dom.remove(); +}); + +function mount(value = "", overrides: Partial = {}) { + props = { ...props, ...overrides, value, cursor: value.length }; + editor = createComposerMarkdownEditor({ + props: () => props, + mountChip: () => {}, + unmountChip: () => {}, + }); + document.body.append(editor.dom); + editor.focus(); + return editor; +} +function key(key: string, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }); + editor.view.contentDOM.dispatchEvent(event); + return event; +} +function submission(key: string, event: KeyboardEvent) { + const snapshot = editor.snapshot(); + const trigger = composerSuggestionsSuppressed(editor.view, props.literalText ?? false) + ? null + : detectComposerTrigger(snapshot.value, snapshot.expandedCursor); + if (trigger?.kind === "emoji" && composerEmojiKeyAction(event) === "select") { + editor.replaceText("💫", { from: trigger.rangeStart, to: trigger.rangeEnd }); + return true; + } + const intent = composerSubmissionIntentForKey({ + event, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + platform: "Linux", + isMobileViewport: false, + isDraftThread: false, + prompt: snapshot.value, + }); + if (intent) { + sends(intent); + return true; + } + return false; +} + +describe("CodeMirror composer behavior", () => { + it("keeps the preceding paragraph plain while starting a dash list", () => { + const intro = "tui up. this is better. my overall points"; + mount(intro, { richTextEnabled: true }); + key("Enter", { shiftKey: true }); + editor.view.dispatch({ ...editor.view.state.replaceSelection("-"), userEvent: "input" }); + + const line = editor.dom.querySelector(".cm-line")!; + const text = line.firstElementChild ?? line; + expect(getComputedStyle(text).textDecoration).not.toContain("underline"); + expect(["bold", "700"]).not.toContain(getComputedStyle(text).fontWeight); + expect(editor.getValue()).toBe(`${intro}\n-`); + + editor.view.dispatch({ + ...editor.view.state.replaceSelection(" first point"), + userEvent: "input", + }); + key("Enter", { shiftKey: true }); + expect(editor.getValue()).toBe(`${intro}\n- first point\n- `); + }); + + it("keeps explicit hash headings formatted", () => { + const value = "# An explicit heading\n\nPlain text"; + mount(value, { richTextEnabled: true }); + expect(editor.dom.querySelector(".cm-lp-h1")?.textContent).toBe("An explicit heading"); + expect(editor.getValue()).toBe(value); + }); + + it.each(["-", "="])("formats an underline heading only after the third %s", (marker) => { + const intro = "A heading when the underline is deliberate"; + mount(`${intro}\n`, { richTextEnabled: true }); + + for (let count = 1; count <= 4; count++) { + editor.view.dispatch({ ...editor.view.state.replaceSelection(marker), userEvent: "input" }); + + const line = editor.dom.querySelector(".cm-line")!; + const text = line.firstElementChild ?? line; + const style = getComputedStyle(text); + expect(style.textDecoration.includes("underline")).toBe(count >= 3); + expect(["bold", "700"].includes(style.fontWeight)).toBe(count >= 3); + expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(count)}`); + } + + editor.view.dispatch({ changes: { from: intro.length + 3, to: editor.view.state.doc.length } }); + const line = editor.dom.querySelector(".cm-line")!; + expect(getComputedStyle(line.firstElementChild ?? line).textDecoration).not.toContain( + "underline", + ); + expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(2)}`); + }); + + it("claims emoji Enter and Tab, keeps a Unicode caret, and never submits", () => { + mount("before :dizzy", { onCommandKeyDown: submission }); + expect(key("Enter").defaultPrevented).toBe(true); + expect(editor.snapshot()).toMatchObject({ + value: "before 💫", + expandedCursor: "before 💫".length, + }); + expect(sends).not.toHaveBeenCalled(); + expect(undo(editor.view)).toBe(true); + expect(editor.getValue()).toBe("before :dizzy"); + expect(redo(editor.view)).toBe(true); + expect(editor.getValue()).toBe("before 💫"); + editor.setValue(":dizzy"); + editor.view.dispatch({ selection: { anchor: 6 } }); + expect(key("Tab").defaultPrevented).toBe(true); + expect(editor.getValue()).toBe("💫"); + expect(sends).not.toHaveBeenCalled(); + }); + + it("respects submission outside code and inserts newlines inside fences", () => { + mount("hello", { onCommandKeyDown: submission }); + key("Enter"); + expect(sends).toHaveBeenCalledTimes(1); + editor.setValue("```ts\nconst x = 1"); + editor.view.dispatch({ selection: { anchor: editor.view.state.doc.length } }); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(true); + key("Enter"); + expect(editor.getValue()).toContain("const x = 1\n"); + expect(sends).toHaveBeenCalledTimes(1); + }); + + it("keeps context source bytes, skips code tokens, and deletes an atomic mention in one key", () => { + const mention = serializeComposerFileLink("src/example.ts"); + mount(`${mention} after`); + const token = composerTokens(editor.view.state, false)[0]!; + expect(editor.getValue()).toBe(`${mention} after`); + expect(editor.dom.querySelectorAll('[data-composer-chip="true"]')).toHaveLength(1); + editor.view.dispatch({ selection: { anchor: token.end } }); + key("Backspace"); + expect(editor.getValue()).toBe(" after"); + undo(editor.view); + expect(editor.getValue()).toBe(`${mention} after`); + editor.setValue(`\` ${mention} \`\n\`\`\`\n${mention}\n\`\`\``); + expect(composerTokens(editor.view.state, false)).toEqual([]); + }); + + it("round-trips citation and skill context as actual atomic source ranges", () => { + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "Quoted text", + start: 0, + end: 11, + prefix: "", + suffix: "", + }); + mount(`${citation} $browser `); + expect(composerTokens(editor.view.state, false).map((token) => token.type)).toEqual([ + "citation", + "skill", + ]); + expect(editor.getValue()).toBe(`${citation} $browser `); + props = { ...props, literalText: true }; + editor.configure(); + expect(composerTokens(editor.view.state, true)).toEqual([]); + expect(editor.getValue()).toBe(`${citation} $browser `); + expect(editor.dom.querySelector('[data-composer-chip="true"]')).toBeNull(); + }); + + it("suppresses suggestions and submission during real composition events", async () => { + const onChange = vi.fn(props.onChange); + mount(":dizzy", { onCommandKeyDown: submission, onChange }); + editor.view.contentDOM.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + expect(onChange.mock.calls.at(-1)?.[3]).toBe(true); + key("Enter", { isComposing: true }); + expect(sends).not.toHaveBeenCalled(); + expect(editor.getValue()).toBe(":dizzy"); + editor.view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })); + await Promise.resolve(); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false); + }); + + it("lets native selection and copy shortcuts pass through readonly input", () => { + mount("readonly", { disabled: true }); + key("a", { ctrlKey: true }); + expect(editor.view.state.selection.main).toMatchObject({ from: 0, to: 8 }); + expect(key("c", { ctrlKey: true }).defaultPrevented).toBe(false); + }); + + it("routes PageDown to the timeline only at the editor's scroll boundary", () => { + const onPageScrollKeyDown = vi.fn(); + mount("line", { onPageScrollKeyDown }); + Object.defineProperties(editor.view.scrollDOM, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 300 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + key("PageDown"); + expect(onPageScrollKeyDown).not.toHaveBeenCalled(); + editor.view.scrollDOM.scrollTop = 200; + expect(key("PageDown").defaultPrevented).toBe(true); + expect(onPageScrollKeyDown).toHaveBeenCalledWith("PageDown"); + }); + + it("keeps readonly drafts copyable and permits only controlled replacement", () => { + mount("readonly", { disabled: true, onCommandKeyDown: submission }); + key("Enter"); + key("b", { ctrlKey: true }); + expect(editor.getValue()).toBe("readonly"); + expect(sends).not.toHaveBeenCalled(); + editor.view.dispatch({ + changes: { from: 0, to: 8, insert: "streamed" }, + annotations: Transaction.userEvent.of("t3.controlled"), + }); + expect(editor.getValue()).toBe("streamed"); + editor.view.dispatch({ selection: { anchor: 0, head: 8 } }); + expect(editor.view.state.selection.main.empty).toBe(false); + }); + + it("continues tasks and keeps the same document and history through preview changes", () => { + mount("- [ ] task", { richTextEnabled: true }); + key("Enter", { shiftKey: true }); + expect(editor.getValue()).toContain("\n- [ ] "); + const draft = editor.getValue(); + props = { ...props, richTextEnabled: false }; + editor.configure(); + expect(editor.getValue()).toBe(draft); + undo(editor.view); + expect(editor.getValue()).toBe("- [ ] task"); + }); +}); + +it("imports actual structured terminal clipboard records and rewrites their atomic references", () => { + const oldId = ComposerContextId.make("terminal-old"); + const newId = ComposerContextId.make("terminal-new"); + const text = formatComposerContextReference({ + kind: "terminal", + contextId: oldId, + label: "Build", + }); + const fragment = { + version: 1 as const, + source: { environmentId: EnvironmentId.make("env") }, + records: [ + { + version: 1 as const, + contextId: oldId, + kind: "terminal" as const, + label: "Build", + terminalId: "build", + terminalLabel: "Build", + lineStart: 1, + lineEnd: 2, + text: "build output", + }, + ], + }; + const records = new Map(); + mount("before ", { + importContextFragment: (incoming) => { + for (const record of incoming.records) records.set(newId, record); + return new Map([[oldId, newId]]); + }, + }); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + files: [], + getData: (format: string) => + format === "text/plain" + ? text + : format === COMPOSER_CONTEXT_CLIPBOARD_MIME + ? encodeComposerContextFragment(fragment) + : "", + }, + }); + editor.view.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(records.get(newId)).toEqual(fragment.records[0]); + expect(editor.snapshot().contextIds).toEqual([newId]); + expect(editor.getValue()).toBe( + `before ${formatComposerContextReference({ kind: "terminal", contextId: newId, label: "Build" })}`, + ); + undo(editor.view); + expect(editor.getValue()).toBe("before "); +}); + +it("keeps literal answers verbatim through formatting shortcuts, lists, and fenced text", () => { + mount("- item", { literalText: true }); + key("Enter", { shiftKey: true }); + expect(editor.getValue()).toBe("- item\n"); + editor.view.dispatch({ selection: { anchor: 0, head: 6 } }); + key("b", { ctrlKey: true }); + expect(editor.getValue()).toBe("- item\n"); +}); + +it.each([false, true])( + "continues fence indentation with Shift=%s and restores it with undo", + (shiftKey) => { + const source = "```ts\n nested()\n```"; + mount(source, { richTextEnabled: true, onCommandKeyDown: submission }); + const cursor = source.indexOf("\n```"); + editor.view.dispatch({ selection: { anchor: cursor } }); + expect(key("Enter", { shiftKey }).defaultPrevented).toBe(true); + expect(editor.getValue()).toBe("```ts\n nested()\n \n```"); + expect(editor.snapshot().expandedCursor).toBe(cursor + 3); + expect(sends).not.toHaveBeenCalled(); + expect(undo(editor.view)).toBe(true); + expect(editor.getValue()).toBe(source); + expect(editor.view.hasFocus).toBe(true); + }, +); + +it.each(["```", "~~~~"])( + "closes an unfinished %s fence after two blank lines and leaves prose", + (fence) => { + const source = `${fence}ts\n nested()\n \n `; + mount(source, { richTextEnabled: true, onCommandKeyDown: submission }); + expect(key("Enter").defaultPrevented).toBe(true); + expect(editor.getValue()).toBe(`${fence}ts\n nested()\n${fence}\n\n`); + expect(editor.snapshot().expandedCursor).toBe(editor.getValue().length); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false); + expect(sends).not.toHaveBeenCalled(); + expect(undo(editor.view)).toBe(true); + expect(editor.getValue()).toBe(source); + }, +); + +it("leaves an already closed fence from its trailing blank body without consuming following prose", () => { + const source = "```ts\nx\n\n\n```\nfollowing"; + mount(source, { richTextEnabled: true, onCommandKeyDown: submission }); + editor.view.dispatch({ selection: { anchor: source.indexOf("\n```") } }); + key("Enter", { shiftKey: true }); + expect(editor.getValue()).toBe("```ts\nx\n```\n\n\nfollowing"); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false); + expect(sends).not.toHaveBeenCalled(); + undo(editor.view); + expect(editor.getValue()).toBe(source); +}); + +it("treats a completed closing fence as the way back to prose, without scaffolding a second fence", () => { + const source = "```ts\n nested()\n```"; + mount(source, { richTextEnabled: true, onCommandKeyDown: submission }); + key("Enter"); + expect(editor.getValue()).toBe(`${source}\n`); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false); + expect(sends).not.toHaveBeenCalled(); + undo(editor.view); + expect(editor.getValue()).toBe(source); +}); + +it("continues two indented code lines before the third Enter returns to prose", () => { + mount("```ts\n nested()", { richTextEnabled: true, onCommandKeyDown: submission }); + key("Enter"); + expect(editor.getValue()).toBe("```ts\n nested()\n "); + key("Enter"); + expect(editor.getValue()).toBe("```ts\n nested()\n \n "); + key("Enter"); + expect(editor.getValue()).toBe("```ts\n nested()\n```\n\n"); + expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false); + expect(sends).not.toHaveBeenCalled(); + expect(editor.view.hasFocus).toBe(true); +}); diff --git a/apps/web/src/components/composerMarkdownEditor.ts b/apps/web/src/components/composerMarkdownEditor.ts new file mode 100644 index 0000000000000000000000000000000000000000..75867d9af03956f85e697ac03b730cdfac8428d3 --- /dev/null +++ b/apps/web/src/components/composerMarkdownEditor.ts @@ -0,0 +1,486 @@ +import { + chipField, + createMarkdownEditor, + enclosingCodeBlock, + insertNewline, + livePreview, + type MarkdownEditor, +} from "@clo/markdown-editor"; +import { isolateHistory, indentLess, indentMore } from "@codemirror/commands"; +import { + defaultHighlightStyle, + HighlightStyle, + syntaxHighlighting, + syntaxTree, +} from "@codemirror/language"; +import { + Compartment, + EditorState, + Prec, + StateEffect, + StateField, + Transaction, +} from "@codemirror/state"; +import type { SyntaxNode } from "@lezer/common"; +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; + +import { cn } from "~/lib/utils"; +import { collectComposerPromptInlineTokens } from "~/composer-editor-mentions"; +import { collapseExpandedComposerCursor, expandCollapsedComposerCursor } from "~/composer-logic"; +import { getTimelinePageScrollKey } from "./chat/pageScrollController"; +import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; +import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; + +export type ComposerToken = ReturnType[number]; +export type ComposerChip = { dom: HTMLElement } & ( + | { kind: "chip"; token: ComposerToken } + | { kind: "fence"; block: ReturnType[number] } +); + +// Live-preview decorations own prose colors; fenced code uses the Shiki extension. +const composerSyntaxHighlight = syntaxHighlighting( + HighlightStyle.define( + defaultHighlightStyle.specs.map((style) => ({ ...style, color: "inherit" })), + ), +); + +export function composerTokens(state: EditorState, literalText: boolean): ComposerToken[] { + if (literalText) return []; + const code: Array<{ from: number; to: number }> = []; + syntaxTree(state).iterate({ + enter(node) { + if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) { + code.push({ from: node.from, to: node.to }); + return false; + } + return undefined; + }, + }); + return collectComposerPromptInlineTokens(state.doc.toString()).filter( + (token) => !code.some((range) => token.start < range.to && token.end > range.from), + ); +} + +export function composerSuggestionsSuppressed(view: EditorView, literalText: boolean): boolean { + const range = view.state.selection.main; + if (literalText || view.compositionStarted || !range.empty) return true; + for ( + let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(range.head, -1); + node; + node = node.parent + ) { + if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) return true; + } + return composerTokens(view.state, literalText).some( + (token) => token.start <= range.head && token.end >= range.head, + ); +} + +class ComposerChipWidget extends WidgetType { + constructor( + readonly token: ComposerToken, + readonly mount: (chip: ComposerChip) => void, + readonly unmount: (dom: HTMLElement) => void, + ) { + super(); + } + override eq(other: ComposerChipWidget) { + return ( + this.token.start === other.token.start && + this.token.end === other.token.end && + this.token.source === other.token.source + ); + } + override toDOM() { + const dom = document.createElement("span"); + dom.contentEditable = "false"; + dom.className = "relative inline-flex select-none items-center align-middle leading-none"; + dom.dataset.composerChip = "true"; + if (this.token.type === "citation") dom.dataset.citationStart = String(this.token.start); + dom.setAttribute( + "aria-label", + this.token.type === "mention" + ? this.token.value + : this.token.type === "skill" + ? this.token.value + : this.token.type === "context-reference" + ? this.token.label + : "Assistant citation", + ); + this.mount({ dom, kind: "chip", token: this.token }); + return dom; + } + override destroy(dom: HTMLElement) { + this.unmount(dom); + } +} + +class ComposerFenceWidget extends WidgetType { + constructor( + readonly block: ReturnType[number], + readonly mount: (chip: ComposerChip) => void, + readonly unmount: (dom: HTMLElement) => void, + ) { + super(); + } + override eq(other: ComposerFenceWidget) { + return ( + this.block.from === other.block.from && + this.block.info === other.block.info && + this.block.codeTo === other.block.codeTo + ); + } + override toDOM() { + const dom = document.createElement("span"); + dom.contentEditable = "false"; + dom.className = "ml-3 inline-flex items-center align-middle font-sans"; + dom.dataset.composerFence = "true"; + this.mount({ dom, kind: "fence", block: this.block }); + return dom; + } + override destroy(dom: HTMLElement) { + this.unmount(dom); + } +} + +export function createComposerMarkdownEditor(options: { + props: () => ComposerPromptEditorProps; + mountChip: (chip: ComposerChip) => void; + unmountChip: (dom: HTMLElement) => void; +}): MarkdownEditor & { + configure: () => void; + snapshot: () => { value: string; cursor: number; expandedCursor: number; contextIds: string[] }; +} { + const presentation = new Compartment(); + const attributes = new Compartment(); + const editing = new Compartment(); + const configuredLiteral = StateEffect.define(); + const literal = StateField.define({ + create: () => options.props().literalText ?? false, + update(value, transaction) { + for (const effect of transaction.effects) + if (effect.is(configuredLiteral)) return effect.value; + return value; + }, + }); + const snapshot = () => { + const view = editor.view; + const value = view.state.doc.toString(); + const expandedCursor = view.state.selection.main.head; + const literalText = view.state.field(literal); + return { + value, + cursor: collapseExpandedComposerCursor(value, expandedCursor, literalText), + expandedCursor, + contextIds: composerTokens(view.state, literalText).flatMap((token) => + token.type === "context-reference" ? [token.contextId] : [], + ), + }; + }; + const report = () => { + const props = options.props(); + const current = snapshot(); + props.onChange( + current.value, + current.cursor, + current.expandedCursor, + composerSuggestionsSuppressed(editor.view, props.literalText ?? false), + current.contextIds, + ); + }; + const editor = createMarkdownEditor({ + preset: "composer", + mentions: false, + preview: false, + doc: options.props().value, + extensions: [ + literal, + presentation.of([]), + attributes.of([]), + editing.of([]), + chipField({ + atomic: true, + onSelection: true, + rebuild: (transaction) => + syntaxTree(transaction.startState) !== syntaxTree(transaction.state), + chips: (state) => + composerTokens(state, state.field(literal)).map((token) => + Decoration.replace({ + widget: new ComposerChipWidget(token, options.mountChip, options.unmountChip), + }).range(token.start, token.end), + ), + }), + chipField({ + onSelection: true, + rebuild: (transaction) => + syntaxTree(transaction.startState) !== syntaxTree(transaction.state), + chips: (state) => + options.props().richTextEnabled && !state.field(literal) + ? composerCodeBlocks(state).map((block) => + Decoration.widget({ + widget: new ComposerFenceWidget(block, options.mountChip, options.unmountChip), + side: 1, + }).range(state.doc.lineAt(block.from).to), + ) + : [], + }), + Prec.highest( + EditorView.domEventHandlers({ + keydown(event, view) { + const props = options.props(); + if (props.disabled) return false; + const pageKey = getTimelinePageScrollKey({ + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + defaultPrevented: event.defaultPrevented, + isComposing: event.isComposing || view.compositionStarted, + keyCode: event.keyCode, + key: event.key, + clientHeight: view.scrollDOM.clientHeight, + scrollHeight: view.scrollDOM.scrollHeight, + scrollTop: view.scrollDOM.scrollTop, + }); + if (pageKey && props.onPageScrollKeyDown) { + props.onPageScrollKeyDown(pageKey); + event.preventDefault(); + event.stopPropagation(); + return true; + } + if (event.isComposing || event.keyCode === 229 || view.compositionStarted) { + if (event.key === "Enter") { + event.preventDefault(); + return true; + } + return false; + } + const range = view.state.selection.main; + if ( + event.key === "Tab" && + event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + range.empty && + !props.literalText + ) { + const citation = composerTokens(view.state, false).find( + (token) => token.type === "citation" && token.end === range.head, + ); + const button = citation + ? view.dom.querySelector( + `[data-citation-start="${citation.start}"] [data-citation-comment-trigger]`, + ) + : null; + if (button) { + event.preventDefault(); + event.stopPropagation(); + button.focus(); + return true; + } + } + const block = enclosingCodeBlock(view.state, range.head); + if (block && !props.literalText && !event.metaKey && !event.ctrlKey) { + if (event.key === "Enter") return false; + if (event.key === "Tab") { + const handled = (event.shiftKey ? indentLess : indentMore)(view); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + return handled; + } + } + const handled = props.onCommandKeyDown?.(event.key, event) ?? false; + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + if (props.literalText) { + if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) { + insertNewline(view); + event.preventDefault(); + return true; + } + if ( + (event.metaKey || event.ctrlKey) && + ["b", "i"].includes(event.key.toLowerCase()) + ) { + event.preventDefault(); + return true; + } + if (["Tab", "*", "`"].includes(event.key)) return true; + } + return false; + }, + compositionstart() { + const props = options.props(); + const current = snapshot(); + props.onChange( + current.value, + current.cursor, + current.expandedCursor, + true, + current.contextIds, + ); + return false; + }, + compositionend(_event, view) { + queueMicrotask(() => { + if (view.dom.isConnected) report(); + }); + return false; + }, + paste(event, view) { + const props = options.props(); + if (props.disabled) return true; + const data = event.clipboardData; + if (!data || event.defaultPrevented) return false; + const text = props.literalText + ? data.getData("text/plain") + : importPastedComposerText(data, props.importContextFragment); + if (!text) return false; + event.preventDefault(); + view.dispatch({ + ...view.state.replaceSelection(text), + annotations: isolateHistory.of("full"), + userEvent: "input.paste", + scrollIntoView: true, + }); + return true; + }, + }), + ), + EditorView.updateListener.of((update) => { + if (update.docChanged || update.selectionSet) { + if ( + update.transactions.some( + (transaction) => transaction.annotation(Transaction.userEvent) === "t3.controlled", + ) + ) + return; + if (!update.state.selection.main.empty) options.props().onVisibleSelectionChange?.(); + report(); + } + }), + Prec.highest( + EditorView.theme({ + "&": { + color: "var(--contrast-foreground)", + fontSize: "inherit", + "--markdown-text": "var(--contrast-foreground)", + "--markdown-muted": "var(--contrast-muted-foreground)", + "--markdown-panel": "var(--muted)", + "--markdown-accent": "var(--info-foreground)", + }, + ".cm-content": { + padding: "0", + caretColor: "var(--contrast-foreground)", + fontFamily: "inherit", + minHeight: "inherit", + }, + ".cm-scroller": { + lineHeight: "inherit", + maxHeight: "inherit", + minHeight: "inherit", + overflowY: "auto", + overflowX: "hidden", + fontFamily: "inherit", + }, + "&.cm-focused": { outline: "none" }, + ".cm-lp-code, .cm-lp-codeblock": { + fontFamily: "var(--font-mono)", + fontSize: "var(--font-size-code, 13px)", + }, + }), + ), + ], + }); + const configure = () => { + const props = options.props(); + const literalText = props.literalText ?? false; + const configuredPreview = presentation.get(editor.view.state); + const previewEnabled = Array.isArray(configuredPreview) && configuredPreview.length > 0; + const showPreview = Boolean(props.richTextEnabled && !literalText); + editor.view.dispatch({ + selection: editor.view.state.selection, + annotations: Transaction.userEvent.of("t3.controlled"), + effects: [ + configuredLiteral.of(literalText), + editing.reconfigure([ + EditorView.editable.of(!props.disabled), + EditorState.readOnly.of(props.disabled), + EditorState.transactionFilter.of((transaction) => + props.disabled && + transaction.docChanged && + transaction.annotation(Transaction.userEvent) !== "t3.controlled" + ? [] + : transaction, + ), + ]), + attributes.reconfigure([ + EditorView.editorAttributes.of({ + class: cn( + "-m-1 block max-h-52 min-h-19.5 p-1 bg-transparent leading-relaxed text-foreground", + props.className, + ), + }), + EditorView.contentAttributes.of({ + "data-testid": "composer-editor", + "data-composer-rich-text": props.richTextEnabled && !literalText ? "true" : "false", + role: "textbox", + "aria-multiline": "true", + "aria-label": props.ariaLabel ?? "Message", + "aria-readonly": String(props.disabled), + "aria-placeholder": props.placeholder, + ...(props.suggestionListId && !props.disabled + ? { "aria-autocomplete": "list", "aria-haspopup": "listbox" } + : {}), + ...(props.activeSuggestionId && props.suggestionListId + ? { + "aria-controls": props.suggestionListId, + "aria-activedescendant": props.activeSuggestionId, + } + : {}), + }), + ]), + ...(showPreview !== previewEnabled + ? [ + presentation.reconfigure( + showPreview + ? [ + composerSyntaxHighlight, + composerCodeBlockHighlight, + livePreview({ + blockWidgets: false, + html: false, + exclude: (state) => + composerTokens(state, false).map((token) => ({ + from: token.start, + to: token.end, + })), + }), + ] + : [], + ), + ] + : []), + ], + }); + }; + configure(); + editor.view.dispatch({ + selection: { + anchor: expandCollapsedComposerCursor( + options.props().value, + options.props().cursor, + options.props().literalText, + ), + }, + annotations: Transaction.userEvent.of("t3.controlled"), + }); + return { ...editor, configure, snapshot }; +} diff --git a/apps/web/src/components/composerSelection.test.ts b/apps/web/src/components/composerSelection.test.ts deleted file mode 100644 index 1d53b43b1f7d7e9b455deff4329385e9e80d4f4a..0000000000000000000000000000000000000000 --- a/apps/web/src/components/composerSelection.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { didComposerSelectionChangeVisibly } from "./composerSelection"; - -describe("didComposerSelectionChangeVisibly", () => { - it("ignores selection-neutral editor updates", () => { - expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, null)).toBe(false); - expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 3, end: 3 })).toBe( - false, - ); - }); - - it("detects entering and extending a visible range selection", () => { - expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 0, end: 3 })).toBe( - true, - ); - expect(didComposerSelectionChangeVisibly({ start: 1, end: 3 }, { start: 0, end: 3 })).toBe( - true, - ); - }); - - it("does not repeatedly report the same visible selection", () => { - expect(didComposerSelectionChangeVisibly({ start: 0, end: 3 }, { start: 0, end: 3 })).toBe( - false, - ); - }); -}); diff --git a/apps/web/src/components/composerSelection.ts b/apps/web/src/components/composerSelection.ts deleted file mode 100644 index 642153f0151cad69dae7effdd888dabb800c2a62..0000000000000000000000000000000000000000 --- a/apps/web/src/components/composerSelection.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type ComposerSelectionRange = { - start: number; - end: number; -}; - -export function didComposerSelectionChangeVisibly( - previous: ComposerSelectionRange, - next: ComposerSelectionRange | null, -): boolean { - return ( - next !== null && - next.start !== next.end && - (next.start !== previous.start || next.end !== previous.end) - ); -} diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index a2fbaa0fb92e96c57198549786e2ef2a1d4def5f..fc13ce75109582d581f98779435140d707ab03eb 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -12,7 +12,7 @@ import { htmlRenderThemeFragment, htmlRenderThemeMessage, htmlRenderResult, - readHtmlRenderContentHeight, + readHtmlRenderContentSize, readHtmlRenderKeyDown, readHtmlRenderLinkRequest, } from "@t3tools/shared/htmlRender"; @@ -122,8 +122,9 @@ export function HtmlRenderDocument(props: { readonly className?: string; /** The thread the page's links open beside, as a chat link would. */ readonly threadRef?: ScopedThreadRef | undefined; - /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ - readonly onContentHeight?: (height: number) => void; + readonly onContentSize?: ( + size: NonNullable>, + ) => void; }) { const theme = useHtmlRenderTheme(); const frameRef = useRef(null); @@ -265,20 +266,20 @@ export function HtmlRenderDocument(props: { window.addEventListener("message", replayKey); return () => window.removeEventListener("message", replayKey); }, []); - const { onContentHeight } = props; - // A page posts its height once per change, so listen from the commit that + const { onContentSize } = props; + // A page posts its size once per change, so listen from the commit that // inserts the frame; a passive effect could run after a fast page's first post. useLayoutEffect(() => { - if (onContentHeight === undefined) return; + if (onContentSize === undefined) return; const resize = (event: MessageEvent) => { - const height = readHtmlRenderContentHeight(event.data); - if (height !== undefined && event.source === frameRef.current?.contentWindow) { - onContentHeight(height); + const size = readHtmlRenderContentSize(event.data); + if (size !== undefined && event.source === frameRef.current?.contentWindow) { + onContentSize(size); } }; window.addEventListener("message", resize); return () => window.removeEventListener("message", resize); - }, [onContentHeight]); + }, [onContentSize]); return ( ")).toBeNull(); + }); + + it("strips event handlers and unknown attributes", () => { + const clean = sanitizeHtml(``)!; + const img = clean.querySelector("img")!; + expect(img.getAttribute("onerror")).toBeNull(); + expect(img.getAttribute("data-x")).toBeNull(); + expect(img.getAttribute("src")).toBe(IMG); + }); + + it("rejects javascript: links and keeps https ones hardened", () => { + const clean = sanitizeHtml('xy')!; + const links = clean.querySelectorAll("a"); + expect(links[0]?.getAttribute("href")).toBeNull(); + expect(links[1]?.getAttribute("href")).toBe("https://ok"); + expect(links[1]?.getAttribute("target")).toBe("_blank"); + expect(links[1]?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("rejects non-image data: urls", () => { + const clean = sanitizeHtml('')!; + expect(clean.querySelector("img")?.getAttribute("src")).toBeNull(); + }); + + it("unwraps harmless unknown tags, keeping their children", () => { + const clean = sanitizeHtml("keep me")!; + expect(clean.querySelector("blink")).toBeNull(); + expect(clean.textContent).toBe("keep me"); + expect(clean.querySelector("b")?.textContent).toBe("me"); + }); + + it("routes relative srcs through resolveUrl and filters srcset entries", () => { + const clean = sanitizeHtml( + ``, + { resolveUrl: (url) => `resolved:${url}` }, + )!; + const img = clean.querySelector("img")!; + expect(img.getAttribute("src")).toBe("resolved:./logo.png"); + expect(img.getAttribute("srcset")).toBe(`resolved:./logo.png 1x, ${IMG} 3x`); + }); + + it("keeps picture/source media queries", () => { + const clean = sanitizeHtml( + ``, + )!; + expect(clean.querySelector("source")?.getAttribute("media")).toBe( + "(prefers-color-scheme: dark)", + ); + }); +}); + +let destroy: (() => void) | undefined; +afterEach(() => { + destroy?.(); + destroy = undefined; +}); + +function mount(doc: string, head = 0): MarkdownEditor { + const editor = createMarkdownEditor({ preset: "document", doc }); + document.body.append(editor.dom); + editor.view.contentDOM.dispatchEvent(new FocusEvent("focus")); + editor.view.dispatch({ selection: EditorSelection.single(head) }); + destroy = () => { + editor.destroy(); + editor.dom.remove(); + }; + return editor; +} + +describe("html blocks in the document preset", () => { + it("renders a balanced block sanitized, away from the caret", () => { + const doc = `

logo

\n\nafter`; + const editor = mount(doc, doc.length); + const html = editor.view.dom.querySelector(".cm-lp-html"); + expect(html?.querySelector("img")?.getAttribute("width")).toBe("120"); + expect(editor.view.dom.textContent).not.toContain(" { + const doc = `

\n\nafter`; + const editor = mount(doc, 3); + expect(editor.view.dom.querySelector(".cm-lp-html")).toBeNull(); + expect(editor.view.dom.querySelectorAll(".cm-lp-html-src").length).toBe(1); + expect(editor.view.dom.textContent).toContain('

'); + }); + + it("never renders script content, even though the block previews", () => { + const doc = "\n\nafter"; + const editor = mount(doc, doc.length); + expect(editor.view.dom.textContent).not.toContain("alert"); + expect(editor.view.dom.querySelector("script")).toBeNull(); + }); + + it("absorbs a split centering wrapper: tags hidden, content centered", () => { + const doc = `

\n\n# Title\n\nsome badges\n\n
\n\nafter`; + const editor = mount(doc, doc.length); + expect(editor.view.dom.textContent).not.toContain(""); + const centered = editor.view.dom.querySelectorAll(".cm-lp-center"); + expect(centered.length).toBeGreaterThanOrEqual(2); + expect(editor.view.dom.querySelector(".cm-lp-h1.cm-lp-center")).not.toBeNull(); + }); + + it("reveals a wrapper tag as source when the caret reaches its line", () => { + const doc = `
\n\ncontent\n\n
\n\nafter`; + const editor = mount(doc, 4); + expect(editor.view.dom.textContent).toContain('
'); + expect(editor.view.dom.querySelector(".cm-lp-html-src")).not.toBeNull(); + }); + + it("leaves an unmatched wrapper open visible as source", () => { + const doc = `
\n\ncontent with no close\n\nafter`; + const editor = mount(doc, doc.length); + expect(editor.view.dom.textContent).toContain('
'); + }); + + it("collapses comment blocks to a placeholder that expands on caret entry", () => { + const doc = "\n\nafter"; + const away = mount(doc, doc.length); + expect(away.view.dom.textContent).not.toContain("secret plan"); + expect(away.view.dom.querySelector(".cm-lp-comment")?.textContent).toBe(""); + destroy?.(); + destroy = undefined; + const inside = mount(doc, 4); + expect(inside.view.dom.textContent).toContain("secret plan"); + expect(inside.view.dom.querySelector(".cm-lp-comment")).toBeNull(); + }); + + it("reveals both halves of an absorbed wrapper together, with token highlighting", () => { + const doc = `
\n\ncontent\n\n
\n\nafter`; + const editor = mount(doc, 4); // caret inside the opening
+ expect(editor.view.dom.textContent).toContain('
'); + expect(editor.view.dom.textContent).toContain("
"); + const tags = [...editor.view.dom.querySelectorAll(".cm-html-tok-tag")]; + expect(tags.map((tag) => tag.textContent)).toEqual(["div", "div"]); + expect(editor.view.dom.querySelector(".cm-html-tok-attr")?.textContent).toBe("align"); + expect(editor.view.dom.querySelector(".cm-html-tok-value")?.textContent).toBe('"center"'); + }); + + it("reveals the opener from the closer too", () => { + const doc = `
\n\ncontent\n\n
\n\nafter`; + const editor = mount(doc, doc.indexOf("
") + 2); + expect(editor.view.dom.textContent).toContain('
'); + expect(editor.view.dom.textContent).toContain("
"); + }); +}); + +describe("inline html in the document preset", () => { + it("styles paired tags and hides them away from the caret", () => { + const doc = "press Cmd and low\n\nafter"; + const editor = mount(doc, doc.length); + expect(editor.view.dom.querySelector(".cm-html-kbd")?.textContent).toBe("Cmd"); + expect(editor.view.dom.querySelector(".cm-html-sub")?.textContent).toBe("low"); + expect(editor.view.dom.textContent).not.toContain(""); + }); + + it("reveals paired tags as muted source when the caret touches the pair", () => { + const editor = mount("press Cmd now\n\nafter", 8); + expect(editor.view.dom.textContent).toContain(""); + expect(editor.view.dom.querySelectorAll(".cm-lp-html-inline").length).toBe(2); + }); + + it("renders
as a line break widget", () => { + const doc = "a
b\n\nafter"; + const editor = mount(doc, doc.length); + expect(editor.view.dom.querySelector(".cm-lp-br br")).not.toBeNull(); + expect(editor.view.dom.textContent).not.toContain("
"); + }); + + it("renders an inline and a badge-style as sanitized widgets", () => { + const doc = `logo and \n\nafter`; + const editor = mount(doc, doc.length); + const widgets = editor.view.dom.querySelectorAll(".cm-lp-html"); + expect(widgets.length).toBe(2); + expect(widgets[1]?.querySelector("a[target='_blank'] img")).not.toBeNull(); + }); + + it("leaves unknown or unpaired tags visible as muted source", () => { + const doc = "a x and unclosed\n\nafter"; + const editor = mount(doc, doc.length); + expect(editor.view.dom.textContent).toContain(""); + expect(editor.view.dom.textContent).toContain(""); + }); + + it("keeps the composer preset html-free (raw text, no widgets)", () => { + const composer = createMarkdownEditor({ doc: `x hi` }); + document.body.append(composer.dom); + expect(composer.dom.querySelector(".cm-lp-html")).toBeNull(); + expect(composer.dom.textContent).toContain("hi"); + composer.destroy(); + composer.dom.remove(); + }); +}); diff --git a/packages/markdown-editor/test/live-preview.test.ts b/packages/markdown-editor/test/live-preview.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5e76c9ea1d5bf1b78255a458fc2fe831ec48f91 --- /dev/null +++ b/packages/markdown-editor/test/live-preview.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +import "./dom"; +// The live-preview decoration layer, asserted through a real EditorView so the StateField, +// provide-wiring, and class names are all exercised — the piece the kiln properties cannot reach. + +import { javascriptLanguage } from "@codemirror/lang-javascript"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { createMarkdownEditor } from "../src/editor.ts"; +import { formattingDecorations } from "../src/live-preview.ts"; + +let cleanup: (() => void) | undefined; +afterEach(() => { + cleanup?.(); + cleanup = undefined; +}); + +function mount(doc: string, head = 0) { + const editor = createMarkdownEditor({ doc }); + document.body.append(editor.dom); + editor.view.contentDOM.dispatchEvent(new FocusEvent("focus")); + editor.view.dispatch({ selection: EditorSelection.single(head) }); + cleanup = () => editor.destroy(); + return editor; +} + +describe("live preview in the view", () => { + it("hides emphasis marks away from the selection and styles the content", () => { + const editor = mount("plain **bold** tail", 0); + const strong = editor.view.dom.querySelector(".cm-lp-strong"); + expect(strong?.textContent).toBe("bold"); + expect(editor.view.dom.textContent).not.toContain("**"); + }); + + it("reveals muted marks when the caret touches the node", () => { + const editor = mount("plain **bold** tail", 9); + expect(editor.view.dom.textContent).toContain("**bold**"); + expect(editor.view.dom.querySelector(".cm-lp-mark")).not.toBeNull(); + }); + + it("boxes fenced code line by line with rounded first and last lines", () => { + const editor = mount("```ts\ncode\n```\n", 0); + const lines = editor.view.dom.querySelectorAll(".cm-lp-codeblock"); + expect(lines.length).toBe(3); + expect(lines[0]?.className).toContain("cm-lp-codeblock-top"); + expect(lines[lines.length - 1]?.className).toContain("cm-lp-codeblock-bottom"); + expect(editor.view.dom.querySelector(".cm-lp-code-lang")?.textContent).toBe("ts"); + }); + + it("uses a consumer-supplied parser to highlight fenced code", () => { + const editor = createMarkdownEditor({ + doc: "```ts\nexport const answer = 42\n```", + codeLanguages: (info: string) => (info === "ts" ? javascriptLanguage : null), + }); + document.body.append(editor.dom); + cleanup = () => editor.destroy(); + const token = [...editor.dom.querySelectorAll(".cm-line span")].find( + (element) => element.textContent === "export", + ); + expect(token?.className).not.toBe(""); + }); +}); + +describe("formattingDecorations bounds", () => { + it("never emits a range outside the document", () => { + const docs = ["", "*a*", "``", "**x** `y` ~~z~~", "```\nunclosed", "- [ ] task *i*"]; + for (const doc of docs) { + const state = EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage })], + }); + for (const range of formattingDecorations(state)) { + expect(range.from).toBeGreaterThanOrEqual(0); + expect(range.to).toBeLessThanOrEqual(doc.length); + } + } + }); +}); + +describe("html stays inert", () => { + it("keeps HTML out of the grammar (no tag nodes, rendered literally)", () => { + const editor = mount("
hi
", 0); + expect(editor.view.dom.textContent).toContain("
hi
"); + }); +}); + +// Sanity for the vitest/happy-dom substrate itself: a view mounts and measures without throwing. +describe("view smoke", () => { + it("mounts, edits, and destroys", () => { + const editor = mount("hello", 0); + editor.view.dispatch({ changes: { from: 5, insert: " world" } }); + expect(editor.getValue()).toBe("hello world"); + }); +}); + +it("leaves host-owned atomic source ranges untouched by link preview", () => { + const value = "before [file.ts](src/file.ts) after"; + const state = EditorState.create({ + doc: value, + extensions: [markdown({ base: markdownLanguage })], + }); + const from = value.indexOf("["); + const to = value.indexOf(" after"); + const ranges = formattingDecorations(state, { exclude: () => [{ from, to }] }); + expect(ranges.some((range) => range.from >= from && range.to <= to)).toBe(false); + expect(state.doc.toString()).toBe(value); +}); + +it("renders readonly tasks as disabled controls and never rewrites their source", () => { + const editor = createMarkdownEditor({ + doc: "- [ ] task", + extensions: [EditorState.readOnly.of(true)], + }); + document.body.append(editor.dom); + cleanup = () => editor.destroy(); + const checkbox = editor.dom.querySelector('input[type="checkbox"]')!; + expect(checkbox.disabled).toBe(true); + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + expect(editor.getValue()).toBe("- [ ] task"); +}); diff --git a/packages/markdown-editor/test/media.test.ts b/packages/markdown-editor/test/media.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef0e651afeafc9fe6b3dad0872388987db54c1a7 --- /dev/null +++ b/packages/markdown-editor/test/media.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import "./dom"; + +import { Compartment, EditorSelection } from "@codemirror/state"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts"; +import { livePreview } from "../src/live-preview.ts"; +import type { VideoPreviewInput, VideoPreviewRenderer } from "../src/widgets.ts"; + +let editor: MarkdownEditor | undefined; + +beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); +}); + +afterEach(() => { + editor?.destroy(); + editor?.dom.remove(); + editor = undefined; + vi.restoreAllMocks(); +}); + +function mount(source: string) { + editor = createMarkdownEditor({ preset: "document", doc: source }); + document.body.append(editor.dom); + return editor; +} + +describe("document video embeds", () => { + it.each(["./clip.mp4", "https://media.example/CLIP.WEBM?token=abc#t=2", "<./my clip.mov>"])( + "plays %s through native controls without editing the source", + (url) => { + const source = `![Demonstration](${url})`; + const instance = mount(source); + const video = instance.dom.querySelector("video")!; + expect(video).not.toBeNull(); + expect(instance.dom.querySelector(".cm-lp-image img")).toBeNull(); + expect(video.controls).toBe(true); + expect(video.autoplay).toBe(false); + expect(video.getAttribute("aria-label")).toBe("Demonstration"); + video.focus(); + expect(document.activeElement).toBe(video); + const key = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }); + video.dispatchEvent(key); + expect(key.defaultPrevented).toBe(false); + expect(instance.getValue()).toBe(source); + expect(instance.dom.querySelector("video")).toBe(video); + }, + ); + + it("recognizes the authored path before host signing and preserves playback through URL refreshes", () => { + const presentation = new Compartment(); + const options = (url: string) => livePreview({ blockWidgets: true, resolveUrl: () => url }); + editor = createMarkdownEditor({ + preset: "document", + preview: false, + doc: "![Clip](clip.mp4)", + extensions: [presentation.of(options("https://host.example/asset?token=first"))], + }); + document.body.append(editor.dom); + const video = editor.dom.querySelector("video")!; + let paused = false; + Object.defineProperty(video, "paused", { configurable: true, get: () => paused }); + Object.defineProperty(video, "duration", { configurable: true, value: 100 }); + video.currentTime = 12; + + editor.view.dispatch({ + effects: presentation.reconfigure(options("https://host.example/asset?token=second")), + }); + expect(editor.dom.querySelector("video")).toBe(video); + expect(video.src).toBe("https://host.example/asset?token=first"); + expect(video.currentTime).toBe(12); + expect(video.pause).not.toHaveBeenCalled(); + + paused = true; + video.dispatchEvent(new Event("pause")); + expect(video.src).toBe("https://host.example/asset?token=second"); + video.currentTime = 0; + video.dispatchEvent(new Event("loadedmetadata")); + expect(video.currentTime).toBe(12); + expect(video.autoplay).toBe(false); + + paused = false; + editor.view.dispatch({ + effects: presentation.reconfigure(options("https://host.example/asset?token=third")), + }); + expect(video.src).toBe("https://host.example/asset?token=second"); + video.dispatchEvent(new Event("error")); + expect(video.src).toBe("https://host.example/asset?token=third"); + expect(editor.dom.querySelector("video")).toBe(video); + }); + + it("stops playback when editing reveals the video source", () => { + const instance = mount("![Clip](clip.mp4)\n\nOther text"); + const video = instance.dom.querySelector("video")!; + instance.view.contentDOM.dispatchEvent(new FocusEvent("focus")); + instance.view.dispatch({ selection: EditorSelection.single(3) }); + expect(instance.dom.querySelector("video")).toBeNull(); + expect(instance.dom.textContent).toContain("![Clip](clip.mp4)"); + expect(video.pause).toHaveBeenCalledOnce(); + }); + + it("does not mistake a video extension inside an image query for a video path", () => { + const instance = mount("![Figure](figure.png?label=clip.mp4)"); + expect(instance.dom.querySelector("video")).toBeNull(); + expect(instance.dom.querySelector(".cm-lp-image img")).not.toBeNull(); + }); + + it("lets the host retain its player through signed URL refreshes and dispose it when removed", () => { + const presentation = new Compartment(); + const update = vi.fn(); + const destroy = vi.fn(); + const inputs: VideoPreviewInput[] = []; + const renderVideo: VideoPreviewRenderer = (input) => { + inputs.push(input); + const dom = document.createElement("span"); + dom.textContent = "Hosted player"; + return { dom, update, destroy }; + }; + const options = (token: string) => + livePreview({ + blockWidgets: true, + renderVideo, + resolveUrl: () => `https://host.example/clip?token=${token}`, + }); + editor = createMarkdownEditor({ + preset: "document", + preview: false, + doc: "![Clip](clip.mp4)", + extensions: [presentation.of(options("first"))], + }); + document.body.append(editor.dom); + const hosted = Array.from(editor.dom.querySelectorAll("span")).find( + (element) => element.textContent === "Hosted player", + )!; + expect(inputs).toEqual([ + { source: "clip.mp4", url: "https://host.example/clip?token=first", alt: "Clip" }, + ]); + expect(editor.dom.querySelector("video")).toBeNull(); + editor.view.dispatch({ effects: presentation.reconfigure(options("second")) }); + expect(hosted.isConnected).toBe(true); + expect(inputs).toHaveLength(1); + expect(update).toHaveBeenCalledWith({ + source: "clip.mp4", + url: "https://host.example/clip?token=second", + alt: "Clip", + }); + expect(destroy).not.toHaveBeenCalled(); + editor.setValue("Video removed"); + expect(destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/markdown-editor/test/mentions.test.ts b/packages/markdown-editor/test/mentions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c3a6f8aa9f94beeac344a6960f591c4ec5cc4de --- /dev/null +++ b/packages/markdown-editor/test/mentions.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +import "./dom"; +// The DOM-bound half of the mention machinery: widget rendering, atomic chips in a live view, and +// the clipboard filters. The pure serialization properties live in mentions.test.ts under kiln. +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createMentionRegistry, + hydrateMentions, + insertMention, + mentionsExtension, + serializeMentions, + staticMentionProvider, +} from "../src/mentions.ts"; + +describe("mention widgets", () => { + it("rehydrates saved file ranges with their line count", () => { + const registry = createMentionRegistry(); + const saved = "review @sys/ui/chat/src/editor.ts:12-18 please"; + const view = new EditorView({ + state: EditorState.create({ + doc: hydrateMentions(registry, saved), + extensions: mentionsExtension(registry), + }), + parent: document.body, + }); + expect(view.dom.querySelector(".chip-label")?.textContent).toBe("editor.ts"); + expect(view.dom.querySelector(".chip-detail")?.textContent).toBe("L12-18"); + expect(serializeMentions(registry, view.state.doc.toString())).toBe(saved); + view.destroy(); + }); + + it("replaces the @query with an atomic chip that serializes back to @token", () => { + const registry = createMentionRegistry(); + const view = new EditorView({ + state: EditorState.create({ doc: "see brok", extensions: mentionsExtension(registry) }), + }); + insertMention( + view, + registry, + { + kind: "file", + label: "session.ts", + token: "sys/agent-backend/src/providers/claude/session.ts", + }, + { from: 4, to: 8 }, + ); + expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe( + "see @sys/agent-backend/src/providers/claude/session.ts", + ); + view.destroy(); + }); + + it("round-trips copied mention text through the clipboard filters", () => { + const registry = createMentionRegistry(); + const view = new EditorView({ + state: EditorState.create({ + doc: "@src/app.ts:3-5", + extensions: mentionsExtension(registry), + }), + }); + const input = view.state.facet(EditorView.clipboardInputFilter); + const output = view.state.facet(EditorView.clipboardOutputFilter); + const hydrated = input.reduce( + (text, filter) => filter(text, view.state), + view.state.doc.toString(), + ); + + expect(hydrated).toMatch(/⟦mention:\d+⟧/); + expect(output.reduce((text, filter) => filter(text, view.state), hydrated)).toBe( + "@src/app.ts:3-5", + ); + view.destroy(); + }); + + it("renders a provided file icon URL in the mention chip", () => { + const registry = createMentionRegistry(); + const view = new EditorView({ + state: EditorState.create({ doc: "@file", extensions: mentionsExtension(registry) }), + parent: document.body, + }); + insertMention( + view, + registry, + { + kind: "file", + label: "app.ts", + token: "src/app.ts", + icon: "data:image/svg+xml;base64,PHN2Zy8+", + }, + { from: 0, to: 5 }, + ); + expect(view.dom.querySelector(".chip-icon img")?.src).toContain( + "data:image/svg+xml;base64,PHN2Zy8+", + ); + view.destroy(); + }); + + it("serializes symbol mentions to their source range token", () => { + const registry = createMentionRegistry(); + const view = new EditorView({ + state: EditorState.create({ doc: "@acti", extensions: mentionsExtension(registry) }), + }); + insertMention( + view, + registry, + { + kind: "symbol", + label: "activate", + detail: "extension · sys/extension/src/extension.ts", + token: "sys/extension/src/extension.ts:12:3", + }, + { from: 0, to: 5 }, + ); + expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe( + "@sys/extension/src/extension.ts:12:3", + ); + view.destroy(); + }); +}); + +describe("staticMentionProvider", () => { + it("substring-filters across label, token, and detail", async () => { + const provider = staticMentionProvider([ + { + kind: "file", + label: "session.ts", + detail: "agent-backend/src/providers/claude", + token: "src/providers/claude/session.ts", + }, + { kind: "symbol", label: "AgentSession", detail: "interface", token: "AgentSession" }, + { kind: "file", label: "editor.ts", detail: "agent-ui/src", token: "src/editor.ts" }, + ]); + const hits = await provider.search("session"); + expect(hits.map((h) => h.label)).toEqual(["session.ts", "AgentSession"]); + expect(await provider.search("agent-ui")).toHaveLength(1); + }); +}); diff --git a/packages/markdown-editor/tsconfig.browser.json b/packages/markdown-editor/tsconfig.browser.json new file mode 100644 index 0000000000000000000000000000000000000000..6fb541829bb5b70d9fc508bf702170f72babd4ba --- /dev/null +++ b/packages/markdown-editor/tsconfig.browser.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "Preserve", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": [], + "erasableSyntaxOnly": false, + "exactOptionalPropertyTypes": false, + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "globalTimers": "off", + "globalDate": "off", + "globalConsole": "off", + "globalFetch": "off", + "globalRandom": "off" + } + } + ] + }, + "include": ["src"] +} diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 21ff82e1b028c730345d308d898aac7d0838835b..3e585051541bb5894d4e286464574a4fd1bfc2f5 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -1,3 +1,4 @@ +import * as NodeVM from "node:vm"; import { describe, expect, it } from "vite-plus/test"; import { @@ -8,7 +9,7 @@ import { htmlRenderThemeFragment, injectHtmlRenderBootstrap, htmlRenderThemeMessage, - readHtmlRenderContentHeight, + readHtmlRenderContentSize, readHtmlRenderKeyDown, readHtmlRenderLinkRequest, readHtmlRenderReference, @@ -19,6 +20,116 @@ import { htmlRenderFromToolItem } from "./toolOutput.ts"; const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; describe("injectHtmlRenderBootstrap", () => { + it("tracks descendant resizing and mutations while preserving the page's authored width", () => { + const listeners = new Map void>(); + const style = new Map([ + ["width", "640px"], + ["priority", "important"], + ]); + let intrinsicWidth = 920; + const sizes: unknown[] = []; + const observed = new Set(); + let resize: (() => void) | undefined; + let mutate: + | ((records: { readonly addedNodes: unknown[]; readonly removedNodes: unknown[] }[]) => void) + | undefined; + const figure = { nodeType: 1, querySelectorAll: () => [] }; + const descendants = [figure]; + const body = { + nodeType: 1, + querySelectorAll: () => descendants, + get scrollWidth() { + return style.get("width") === "max-content" ? intrinsicWidth : 640; + }, + }; + class ResizeObserver { + constructor(callback: () => void) { + resize = callback; + } + observe(target: unknown) { + observed.add(target); + } + unobserve(target: unknown) { + observed.delete(target); + } + } + class MutationObserver { + constructor(callback: NonNullable) { + mutate = callback; + } + observe(target: unknown, options: unknown) { + expect(target).toBe(body); + expect(options).toEqual({ + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + } + } + const root = { + clientHeight: 100, + scrollHeight: 240, + getBoundingClientRect: () => ({ + width: style.get("width") === "max-content" ? intrinsicWidth : 640, + height: 240, + }), + style: { + getPropertyValue: (property: string) => style.get(property), + getPropertyPriority: () => style.get("priority"), + setProperty: (property: string, value: string, priority: string) => { + style.set(property, value); + style.set("priority", priority); + }, + removeProperty: (property: string) => style.delete(property), + }, + }; + const bootstrap = + injectHtmlRenderBootstrap("

Figure

").match(/ unsafe', + ), + ); + expect(tree.children[0]).toMatchObject({ + tagName: "p", + children: expect.arrayContaining([ + expect.objectContaining({ + tagName: "kbd", + properties: {}, + children: [expect.objectContaining({ type: "text", value: "Cmd" })], + }), + expect.objectContaining({ + tagName: "mark", + properties: {}, + children: [expect.objectContaining({ type: "text", value: "marked" })], + }), + expect.objectContaining({ + tagName: "u", + properties: {}, + children: [expect.objectContaining({ type: "text", value: "underlined" })], + }), + ]), + }); + const rendered = JSON.stringify(tree); + expect(rendered).not.toContain("javascript:"); + expect(rendered).not.toContain('"tagName":"script"'); + expect(rendered).not.toContain("onclick"); + expect(rendered).not.toContain("onmouseover"); + expect(rendered).not.toContain('"style"'); +}); diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 7317dcc5b9fff40af500c2c1f486244be868cd16..8ce971e8facca5d8415bbd7a6f732e026388efa6 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -142,6 +142,7 @@ function rehypePreserveImageSourceMeta() { const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), "mark", "u"], attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f466898da28f87d7d201187eef466181f821415d..d0968f6d65a1ee228315062c0c2bf78801f85fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -760,9 +760,27 @@ importers: '@clerk/react': specifier: 6.16.1 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clo/markdown-editor': + specifier: workspace:* + version: link:../../packages/markdown-editor '@clo/terminal': specifier: workspace:* version: link:../../packages/terminal + '@codemirror/commands': + specifier: ^6.8.1 + version: 6.11.1 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.2 + '@codemirror/language': + specifier: ^6.12.3 + version: 6.13.1 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.7.6 + '@codemirror/view': + specifier: ^6.43.1 + version: 6.43.14 '@daypicker/react': specifier: ^10.0.1 version: 10.0.1(@types/react@19.3.0)(react@19.2.6) @@ -787,6 +805,9 @@ importers: '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@lezer/common': + specifier: ^1.5.2 + version: 1.5.3 '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -832,42 +853,6 @@ importers: '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tiptap/core': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-blockquote': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-code-block': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-heading': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-horizontal-rule': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-task-item': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-task-list': - specifier: ^3.31.3 - version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/pm': - specifier: ^3.31.3 - version: 3.31.3 - '@tiptap/react': - specifier: ^3.31.3 - version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tiptap/starter-kit': - specifier: ^3.31.3 - version: 3.31.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -880,6 +865,9 @@ importers: effect: specifier: 4.0.4 version: 4.0.4 + gemoji: + specifier: ^8.1.0 + version: 8.1.0 hast-util-to-html: specifier: ^9.0.5 version: 9.0.5 @@ -1185,6 +1173,39 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/markdown-editor: + devDependencies: + '@codemirror/commands': + specifier: ^6.8.1 + version: 6.11.1 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.2 + '@codemirror/language': + specifier: ^6.12.3 + version: 6.13.1 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.7.6 + '@codemirror/view': + specifier: ^6.43.1 + version: 6.43.14 + '@lezer/common': + specifier: ^1.5.2 + version: 1.5.3 + '@types/react': + specifier: ~19.3.0 + version: 19.3.0 + react: + specifier: ^19.2.0 + version: 19.3.0 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-acp: dependencies: '@t3tools/contracts': @@ -2848,6 +2869,39 @@ packages: '@cloudflare/workers-types@5.20261004.1': resolution: {integrity: sha512-K9D4/Y6bSIDNYFzjqzFMNoWK99pLgfgFxqSa76kfW++3VCkTSzJbzsQRffir366G3kmNyjoigC6ekp7DxIkxkw==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-markdown@6.5.2': + resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.14': + resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==} + '@coderabbitai/config@1.1.0': resolution: {integrity: sha512-NvQKaoOD7fWb7lMKhid57ABVlIBSczw/S+ePk5YvoPuWdrDkyY+PWdqVtq3TRq7VY8f3SNRlMFjmAtXVab66pw==} @@ -4190,6 +4244,27 @@ packages: react-native: optional: true + '@lezer/common@1.5.3': + resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==} + + '@lezer/css@1.3.9': + resolution: {integrity: sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.6': + resolution: {integrity: sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==} + + '@lezer/lr@1.4.11': + resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==} + + '@lezer/markdown@1.8.0': + resolution: {integrity: sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==} + '@libsql/client@0.17.3': resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} @@ -4255,6 +4330,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} @@ -6045,166 +6123,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tiptap/core@3.31.3': - resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} - peerDependencies: - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-blockquote@3.31.3': - resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-bold@3.31.3': - resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-bubble-menu@3.31.3': - resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-bullet-list@3.31.3': - resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-code-block@3.31.3': - resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-code@3.31.3': - resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-document@3.31.3': - resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-dropcursor@3.31.3': - resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} - peerDependencies: - '@tiptap/extensions': 3.31.3 - - '@tiptap/extension-floating-menu@3.31.3': - resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} - peerDependencies: - '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-gapcursor@3.31.3': - resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} - peerDependencies: - '@tiptap/extensions': 3.31.3 - - '@tiptap/extension-hard-break@3.31.3': - resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-heading@3.31.3': - resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-horizontal-rule@3.31.3': - resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-italic@3.31.3': - resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-link@3.31.3': - resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-list-item@3.31.3': - resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-list-keymap@3.31.3': - resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-list@3.31.3': - resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-ordered-list@3.31.3': - resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-paragraph@3.31.3': - resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-strike@3.31.3': - resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-task-item@3.31.3': - resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-task-list@3.31.3': - resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} - peerDependencies: - '@tiptap/extension-list': 3.31.3 - - '@tiptap/extension-text@3.31.3': - resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extension-underline@3.31.3': - resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} - peerDependencies: - '@tiptap/core': 3.31.3 - - '@tiptap/extensions@3.31.3': - resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - - '@tiptap/pm@3.31.3': - resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - - '@tiptap/react@3.31.3': - resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} - peerDependencies: - '@tiptap/core': 3.31.3 - '@tiptap/pm': 3.31.3 - '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tiptap/starter-kit@3.31.3': - resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} - '@tsc-rs/darwin-arm64@0.7.0': resolution: {integrity: sha512-3execGkNh7DXNZr03dhQP2wqmsjB1Q4OJnTMgKIibjeQMsjNK8HTalJZPIG6PkiXeusXUweHP3+4yJjbi5auNw==} engines: {node: '>=16.20.0'} @@ -6501,9 +6419,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -7807,6 +7722,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -8894,10 +8812,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.2: - resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} - engines: {node: '>=6.0.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -9081,6 +8995,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gemoji@8.1.0: + resolution: {integrity: sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -9677,9 +9594,6 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} - linkifyjs@4.3.3: - resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} - locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -10391,9 +10305,6 @@ packages: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} - orderedmap@2.1.1: - resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -10731,45 +10642,6 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - prosemirror-changeset@2.4.2: - resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} - - prosemirror-commands@1.7.2: - resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} - - prosemirror-dropcursor@1.8.3: - resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} - - prosemirror-gapcursor@1.4.1: - resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} - - prosemirror-history@1.5.0: - resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} - - prosemirror-inputrules@1.5.1: - resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} - - prosemirror-keymap@1.2.3: - resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - - prosemirror-model@1.25.11: - resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} - - prosemirror-schema-list@1.5.1: - resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} - - prosemirror-state@1.4.4: - resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - - prosemirror-tables@1.8.5: - resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - - prosemirror-transform@1.12.1: - resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} - - prosemirror-view@1.42.3: - resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -11142,9 +11014,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rope-sequence@1.3.4: - resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} - roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -11500,6 +11369,9 @@ packages: structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -13662,6 +13534,89 @@ snapshots: '@cloudflare/workers-types@5.20261004.1': {} + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 + '@lezer/css': 1.3.9 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/css': 1.3.9 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.13.1 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/javascript': 1.5.6 + + '@codemirror/lang-markdown@6.5.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/markdown': 1.8.0 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + style-mod: 4.1.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + crelt: 1.0.7 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.14': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + '@coderabbitai/config@1.1.0': dependencies: zod: 4.5.4 @@ -15068,6 +15023,39 @@ snapshots: react-dom: 19.3.0(react@19.3.0) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) + '@lezer/common@1.5.3': {} + + '@lezer/css@1.3.9': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.3 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + + '@lezer/javascript@1.5.6': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + + '@lezer/lr@1.4.11': + dependencies: + '@lezer/common': 1.5.3 + + '@lezer/markdown@1.8.0': + dependencies: + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@libsql/core': 0.17.3 @@ -15139,6 +15127,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.4': {} + '@material/material-color-utilities@0.3.0': {} '@mermaid-js/parser@1.2.1': @@ -16623,187 +16613,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - optional: true - - '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - optional: true - - '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - linkifyjs: 4.3.3 - - '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - - '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - - '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - - '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - - '@tiptap/pm@3.31.3': - dependencies: - prosemirror-changeset: 2.4.2 - prosemirror-commands: 1.7.2 - prosemirror-dropcursor: 1.8.3 - prosemirror-gapcursor: 1.4.1 - prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.5 - prosemirror-transform: 1.12.1 - prosemirror-view: 1.42.3 - - '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - '@types/react': 19.3.0 - '@types/react-dom': 19.2.3(@types/react@19.3.0) - '@types/use-sync-external-store': 0.0.6 - fast-equals: 5.4.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - transitivePeerDependencies: - - '@floating-ui/dom' - - '@tiptap/starter-kit@3.31.3': - dependencies: - '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/pm': 3.31.3 - '@tsc-rs/darwin-arm64@0.7.0': optional: true @@ -17145,8 +16954,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@0.0.6': {} - '@types/webidl-conversions@7.0.3': optional: true @@ -18420,6 +18227,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crelt@1.0.7: {} + cross-dirname@0.1.0: optional: true @@ -19564,8 +19373,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@5.4.2: {} - fast-json-stable-stringify@2.1.0: optional: true @@ -19615,10 +19422,6 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -19771,6 +19574,8 @@ snapshots: function-bind@1.1.2: {} + gemoji@8.1.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -20418,8 +20223,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - linkifyjs@4.3.3: {} - locate-path@3.0.0: dependencies: p-locate: 3.0.0 @@ -21448,8 +21251,6 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 - orderedmap@2.1.1: {} - outvariant@1.4.3: optional: true @@ -21901,80 +21702,6 @@ snapshots: property-information@7.2.0: {} - prosemirror-changeset@2.4.2: - dependencies: - prosemirror-transform: 1.12.1 - - prosemirror-commands@1.7.2: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - - prosemirror-dropcursor@1.8.3: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - prosemirror-view: 1.42.3 - - prosemirror-gapcursor@1.4.1: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-view: 1.42.3 - - prosemirror-history@1.5.0: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - prosemirror-view: 1.42.3 - rope-sequence: 1.3.4 - - prosemirror-inputrules@1.5.1: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - - prosemirror-keymap@1.2.3: - dependencies: - prosemirror-state: 1.4.4 - w3c-keyname: 2.2.8 - - prosemirror-model@1.25.11: - dependencies: - orderedmap: 2.1.1 - - prosemirror-schema-list@1.5.1: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - - prosemirror-state@1.4.4: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-transform: 1.12.1 - prosemirror-view: 1.42.3 - - prosemirror-tables@1.8.5: - dependencies: - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - prosemirror-view: 1.42.3 - - prosemirror-transform@1.12.1: - dependencies: - prosemirror-model: 1.25.11 - - prosemirror-view@1.42.3: - dependencies: - prosemirror-model: 1.25.11 - prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.1 - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -22523,8 +22250,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 - rope-sequence@1.3.4: {} - roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -22949,6 +22674,8 @@ snapshots: structured-headers@0.4.1: {} + style-mod@4.1.4: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -23076,8 +22803,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.1.2: {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index e2fd1d86317c18bd8bbc83e4e7c71273feb8c52a..12fcaff088e6bbe6990d57c20f8ee72122a1e32c 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -99,6 +99,14 @@ "noticeFile": "native/libghostty-vt/LICENSE", "sourceUrl": "https://github.com/ghostty-org/ghostty" }, + { + "bundles": ["assets", "web"], + "license": "OFL-1.1", + "name": "Recursive", + "noticeFile": "apps/web/src/assets/fonts/Recursive-OFL.txt", + "sourceUrl": "https://github.com/arrowtype/recursive", + "version": "1.085" + }, { "bundles": ["assets", "web"], "license": "OFL-1.1",