diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index dbaeed2695a..3d01c86af75 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -170,6 +170,7 @@ export const Composer = ({ { + const rel = cleanPath(cwd) + return rel ? `${rel}/` : "" +} + +const basename = (path: string): string => path.split("/").pop() ?? path + +/** Folders above files, then alphabetical — the order every drive surface lists in. */ +const byFolderThenName = (a: PaletteFileRow, b: PaletteFileRow): number => + Number(b.isFolder) - Number(a.isFolder) || a.name.localeCompare(b.name) + +const rowFor = (file: MountFile, path: string, isFolder: boolean): PaletteFileRow => ({ + path, + name: basename(path), + isFolder, + size: isFolder ? undefined : (file.size ?? undefined), + itemCount: file.item_count ?? undefined, +}) + +/** + * The immediate children of `cwd`. A listing may hold deeper entries (every loaded directory + * accumulates into one array), so a path that reaches further down contributes its next segment as + * an implied folder rather than a row of its own. + */ +export function browseRows(files: MountFile[], cwd: string): PaletteFileRow[] { + const prefix = prefixFor(cwd) + const rows = new Map() + for (const file of files) { + const rel = cleanPath(file.path) + if (!rel.startsWith(prefix)) continue + const rest = rel.slice(prefix.length) + if (!rest) continue + // Judge the LISTING entry, not the folder it implies: the bare `agent-files` marker is + // unlistable, but the agent files under it fold into a folder that very much is. + if (!isListableDrivePath(rel)) continue + const cut = rest.indexOf("/") + const path = cut < 0 ? rel : prefix + rest.slice(0, cut) + const isFolder = cut >= 0 || Boolean(file.is_folder) + // An implied folder must not inherit the descendant's size or count. + const existing = rows.get(path) + if (existing && (existing.isFolder || !isFolder)) continue + rows.set( + path, + cut < 0 ? rowFor(file, path, isFolder) : {path, name: basename(path), isFolder: true}, + ) + } + return [...rows.values()].sort(byFolderThenName) +} + +/** + * Everything under `cwd` whose path contains `query`. Plain case-insensitive substring on the full + * path — the palette highlights the match by re-deriving it from the row's label, so a looser + * predicate here returns rows that come back matched but unhighlighted. + */ +export function searchRows( + files: MountFile[], + cwd: string, + query: string, + cap: number = FILE_PALETTE_ROW_CAP, +): PaletteFileRow[] { + const q = query.trim().toLowerCase() + if (!q) return [] + const prefix = prefixFor(cwd) + const rows: PaletteFileRow[] = [] + for (const file of files) { + const rel = cleanPath(file.path) + if (!rel.startsWith(prefix) || !isListableDrivePath(rel)) continue + if (!rel.toLowerCase().includes(q)) continue + rows.push(rowFor(file, rel, Boolean(file.is_folder))) + } + return rows.sort(byFolderThenName).slice(0, cap) +} + +/** The drive's most-recently-touched files, for the palette's opening list. */ +export function recentRows( + recents: DriveRecentFile[], + limit: number = FILE_PALETTE_RECENTS, +): PaletteFileRow[] { + const rows: PaletteFileRow[] = [] + for (const file of recents) { + if (rows.length >= limit) break + const rel = cleanPath(file.path) + if (!isListableDrivePath(rel) || file.is_folder) continue + rows.push({...rowFor(file, rel, false), touchedAt: file.touchedAt}) + } + return rows +} + +/** The directory a path sits in, as a presented drive path (`""` at the root). */ +export const parentPath = (path: string): string => { + const rel = cleanPath(path) + const cut = rel.lastIndexOf("/") + return cut < 0 ? "" : rel.slice(0, cut) +} diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 3dc5493b5b1..fedaee23406 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -10,3 +10,4 @@ export * from "./conversationLayout" export * from "./jumpToLatest" export * from "./boundedRequest" export {startupLabelFromDataPart} from "./startupPhases" +export * from "./filePaletteRows" diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index e16698e9b00..d85f7c2a42a 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -16,6 +16,7 @@ import {Paperclip} from "@phosphor-icons/react" import {acceptAttrFor} from "../assets/attachmentRules" import type {useComposerAttachments} from "../hooks/useComposerAttachments" +import {useFilePalette} from "../hooks/useFilePalette" import {useHardwareKeyboard} from "../hooks/useHardwareKeyboard" import ComposerAttachments from "./ComposerAttachments" @@ -65,6 +66,11 @@ export interface ChatComposerProps { trailing?: ReactNode /** The `/` palette's sections. Omit where the surface has no commands. */ slashCommands?: SlashCommandSection[] + /** + * Enable the `@` file palette. Needs an enclosing `DriveSessionProvider`; off by default so the + * surfaces that run before a session exists (onboarding, the home task composer) are untouched. + */ + fileMentions?: boolean /** Suspense fallback while the Lexical chunk hydrates (hosts pass their skeleton). */ fallback?: ReactNode } @@ -91,6 +97,7 @@ export const ChatComposer = ({ extraPrefix, trailing, slashCommands, + fileMentions, fallback, }: ChatComposerProps) => { const { @@ -113,9 +120,12 @@ export const ChatComposer = ({ // take room from a placeholder that is already tight. `isMacPlatform` reads the UA, so a real // iPhone was being shown the `⌘` variant specifically. const hasKeyboard = useHardwareKeyboard() + const filePalette = useFilePalette({enabled: fileMentions}) return ( + {/* Renders null; it holds the `@` palette's per-directory listings. */} + {filePalette.subscribers} { if (!attachmentsBlocked?.()) addFiles(Array.from(pasted)) diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts index 3a49e87ec92..5d60822e206 100644 --- a/web/packages/agenta-chat/src/hooks/index.ts +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -16,3 +16,4 @@ export * from "./useVoiceComposer" export * from "./useSessionChat" export * from "./useTypewriter" export * from "./useHardwareKeyboard" +export * from "./useFilePalette" diff --git a/web/packages/agenta-chat/src/hooks/useFilePalette.tsx b/web/packages/agenta-chat/src/hooks/useFilePalette.tsx new file mode 100644 index 00000000000..089f13cae60 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useFilePalette.tsx @@ -0,0 +1,275 @@ +/** + * The composer's `@` file palette — the session drive as a trigger menu. + * + * Lives here, not in `@agenta/ui`, because it needs drive data the UI package may not import. The + * palette contract carries every visual as a ReactNode, so the renderer stays drive-free while this + * hook supplies the icons, origin pills and breadcrumb. + * + * `subscribers` must be rendered by the caller: it holds the per-directory queries. + */ +import {useCallback, useDeferredValue, useMemo, useState, type ReactNode} from "react" + +import { + driveRootLabel, + fileOrigin, + humanSize, + relativeTime, + useDelayedTrue, + useSessionDriveSummary, +} from "@agenta/entities/drive" +import { + DriveBreadcrumb, + driveFileIcon, + useDriveArtifactId, + useDriveSessionId, + useLazyDriveTree, +} from "@agenta/entity-ui/drive" +import { + HintKey, + type PaletteItem, + type PaletteSection, + type PaletteSpec, +} from "@agenta/ui/rich-chat-input" +import {CircleNotch, FolderOpen, FolderSimple, MagnifyingGlass} from "@phosphor-icons/react" + +import { + browseRows, + parentPath, + recentRows, + searchRows, + type PaletteFileRow, +} from "../assets/filePaletteRows" + +/** Long enough that `@r` on the way to `@report.md` never fires the whole-tree fetch. */ +const SEARCH_DELAY_MS = 180 + +export interface FilePalette { + /** Undefined when disabled or outside a conversation — the composer then mounts no `@` menu. */ + spec?: PaletteSpec + /** Render this (it renders null); it drives the per-directory listings. */ + subscribers: ReactNode +} + +export function useFilePalette({enabled = false}: {enabled?: boolean} = {}): FilePalette { + const sessionId = useDriveSessionId() ?? "" + const artifactId = useDriveArtifactId() ?? undefined + const active = enabled && Boolean(sessionId) + + const [query, setQuery] = useState(null) + const [cwd, setCwd] = useState("") + + // The summary drive: record-log recents plus a count, no whole-tree listing to open a menu. + const drive = useSessionDriveSummary(active ? sessionId : "", active ? artifactId : undefined) + + const deferredQuery = useDeferredValue(query ?? "") + const searchActive = useDelayedTrue(deferredQuery.trim() !== "", SEARCH_DELAY_MS) + // Empty while closed, so no directory subscriber exists until the user types `@`. + const activePaths = useMemo(() => (query === null ? [] : [...new Set(["", cwd])]), [query, cwd]) + const lazy = useLazyDriveTree(drive, activePaths, searchActive, false) + + const onQueryChange = useCallback((next: string | null) => { + setQuery(next) + // A reopened `@` always starts at the root. + if (next === null) setCwd("") + }, []) + + // Consume Escape only while there is a level to step out of; at the root the plugin closes. + const onEscape = useCallback(() => { + if (!cwd) return false + setCwd(parentPath(cwd)) + return true + }, [cwd]) + + const searching = deferredQuery.trim() !== "" + + const toItem = useCallback( + (row: PaletteFileRow, keyPrefix: string): PaletteItem => ({ + key: `${keyPrefix}:${row.path}`, + // The full path while searching, so the highlight lands where the match is. + label: searching ? row.path : row.name + (row.isFolder ? "/" : ""), + icon: row.isFolder ? ( + + ) : ( + driveFileIcon(row.path, 14) + ), + secondary: + !searching && keyPrefix === "recent" && parentPath(row.path) + ? `${parentPath(row.path)}/` + : undefined, + tail: row.isFolder + ? row.itemCount + ? `${row.itemCount} items` + : "open" + : [humanSize(row.size), row.touchedAt ? relativeTime(row.touchedAt) : ""] + .filter(Boolean) + .join(" · "), + kind: "insert", + // The reference the agent receives: the presented drive path, `agent-files/` fold + // included, which is the name the runner symlinks into the session's working folder. + insertText: row.isFolder ? `${row.path}/` : row.path, + insertAs: "code", + onDrillIn: row.isFolder ? () => setCwd(row.path) : undefined, + }), + [searching], + ) + + /** + * Split a level by scope. Two headings say what a per-row tag used to, without spending a pill + * on every row. A level that is all one scope needs neither, so it stays a single list. + */ + const byOrigin = useCallback( + (rows: PaletteFileRow[], keyPrefix: string): PaletteSection[] => { + const session = rows.filter((r) => fileOrigin(r.path) === "session") + const agent = rows.filter((r) => fileOrigin(r.path) === "agent") + if (!session.length || !agent.length) { + return rows.length + ? [{key: keyPrefix, title: "", items: rows.map((r) => toItem(r, keyPrefix))}] + : [] + } + return [ + { + key: `${keyPrefix}-session`, + title: "Session", + items: session.map((r) => toItem(r, keyPrefix)), + }, + { + key: `${keyPrefix}-agent`, + title: "Agent", + items: agent.map((r) => toItem(r, keyPrefix)), + }, + ] + }, + [toItem], + ) + + const sections = useMemo(() => { + if (!active) return [] + if (searching) return byOrigin(searchRows(lazy.files, cwd, deferredQuery), "hit") + const level = browseRows(lazy.files, cwd) + // Inside a folder every row shares one scope, so there is nothing to group by. + if (cwd) { + return level.length + ? [{key: "level", title: "", items: level.map((r) => toItem(r, "level"))}] + : [] + } + const recents = recentRows(drive.recents) + const recentPaths = new Set(recents.map((r) => r.path)) + return [ + ...(recents.length + ? [ + { + key: "recent", + title: "Recently touched", + items: recents.map((r) => toItem(r, "recent")), + }, + ] + : []), + // A file already listed above would read as a duplicate row. + ...byOrigin( + level.filter((r) => !recentPaths.has(r.path)), + "root", + ), + ].filter((section) => section.items.length > 0) + }, [active, searching, byOrigin, lazy.files, cwd, deferredQuery, drive.recents, toItem]) + + // While the search is still held back, say so rather than reporting the loaded dirs as the + // whole answer — an empty state here would claim the drive holds no match. + const loading = searching ? !searchActive || lazy.searchLoading : !lazy.loadedDirs.has(cwd) + + const header = useMemo(() => { + const status = loading ? ( + + + listing… + + ) : null + if (cwd) { + return ( + <> + {/* No leading icon: DriveBreadcrumb opens with its own home button. */} + + {status} + + ) + } + return ( + <> + {searching ? ( + + ) : ( + + )} + Files + {searching ? ( + + across the drive + + ) : null} + {status} + + ) + }, [cwd, drive.mount, loading, searching]) + + const footer = useCallback( + (activeItem: PaletteItem | undefined): ReactNode => ( + <> + + + {activeItem?.onDrillIn ? : null} + + {cwd ? ( + // Truncates rather than wraps: a deep path must not push the keys onto a second line. + + searching inside {cwd}/ + + ) : null} + + ), + [cwd], + ) + + const spec = useMemo(() => { + if (!active) return undefined + return { + key: "files", + trigger: "@", + // Paths hold slashes, so the query must too — `@docs/gui` stays one run. + allowSlashInQuery: true, + label: "Files", + sections, + // Rows arrive already filtered and capped; re-ranking them here would fight the order. + filterMode: "none", + onQueryChange, + onEscape, + header, + footer, + loading, + emptyText: (q) => + q ? ( + <> + No file or folder matches “{q}” +
+ Enter sends the message as written. +
+ + ) : ( + "No files in this drive yet" + ), + } + }, [active, sections, onQueryChange, onEscape, header, footer, loading]) + + return {spec, subscribers: lazy.subscribers} +} diff --git a/web/packages/agenta-chat/tests/unit/filePaletteRows.test.ts b/web/packages/agenta-chat/tests/unit/filePaletteRows.test.ts new file mode 100644 index 00000000000..ca2791bc0fa --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/filePaletteRows.test.ts @@ -0,0 +1,103 @@ +import {describe, expect, it} from "vitest" + +import {browseRows, parentPath, recentRows, searchRows} from "../../src/assets/filePaletteRows" + +const file = (path: string, extra: Record = {}) => ({path, ...extra}) + +const LISTING = [ + file("AGENTS.md", {size: 2300}), + file("README.md", {size: 293}), + file("audits/", {is_folder: true, item_count: 4}), + file("audits/2026-08/slop-report.md", {size: 14000}), + file("agent-files/notes.md", {size: 40}), + file(".agenta-runner/state.json", {size: 1}), +] + +describe("browseRows", () => { + it("lists the root's own entries, folding deeper paths into their folder", () => { + expect(browseRows(LISTING, "").map((r) => r.path)).toEqual([ + "agent-files", + "audits", + "AGENTS.md", + "README.md", + ]) + }) + + it("scopes to a folder, and names rows by their basename", () => { + expect(browseRows(LISTING, "audits")).toEqual([ + {path: "audits/2026-08", name: "2026-08", isFolder: true}, + ]) + }) + + it("drops runner plumbing", () => { + expect(browseRows(LISTING, "").some((r) => r.path.startsWith(".agenta"))).toBe(false) + }) + + it("keeps a folder's own count off an implied folder", () => { + expect(browseRows(LISTING, "").find((r) => r.path === "audits")?.itemCount).toBe(4) + }) +}) + +describe("searchRows", () => { + it("matches anywhere in the path", () => { + expect(searchRows(LISTING, "", "report").map((r) => r.path)).toEqual([ + "audits/2026-08/slop-report.md", + ]) + }) + + it("puts folders above the files that match the same term", () => { + expect(searchRows(LISTING, "", "audits").map((r) => r.path)).toEqual([ + "audits", + "audits/2026-08/slop-report.md", + ]) + }) + + it("scopes to the folder in view", () => { + expect(searchRows(LISTING, "audits", "md").map((r) => r.path)).toEqual([ + "audits/2026-08/slop-report.md", + ]) + expect(searchRows(LISTING, "audits", "AGENTS")).toEqual([]) + }) + + it("reaches the agent mount through its fold prefix", () => { + expect(searchRows(LISTING, "", "notes").map((r) => r.path)).toEqual([ + "agent-files/notes.md", + ]) + }) + + it("caps the result set", () => { + const many = Array.from({length: 80}, (_, i) => file(`docs/page-${i}.md`)) + expect(searchRows(many, "", "page", 30)).toHaveLength(30) + }) + + it("returns nothing for a blank query", () => { + expect(searchRows(LISTING, "", " ")).toEqual([]) + }) +}) + +describe("recentRows", () => { + it("keeps order, drops folders, and honours the limit", () => { + const recents = [ + file("audits/2026-08/slop-report.md", {touchedAt: 3}), + file("audits/", {is_folder: true}), + file("README.md", {touchedAt: 2}), + // A third eligible row, so an ignored limit would show up here. + file("AGENTS.md", {touchedAt: 1}), + ] + expect(recentRows(recents, 2).map((r) => r.path)).toEqual([ + "audits/2026-08/slop-report.md", + "README.md", + ]) + }) + + it("returns nothing for a zero limit", () => { + expect(recentRows([file("README.md", {touchedAt: 1})], 0)).toEqual([]) + }) +}) + +describe("parentPath", () => { + it("walks one level, and stops at the root", () => { + expect(parentPath("audits/2026-08/x.md")).toBe("audits/2026-08") + expect(parentPath("audits")).toBe("") + }) +}) diff --git a/web/packages/agenta-entity-ui/src/drive/OriginTag.tsx b/web/packages/agenta-entity-ui/src/drive/OriginTag.tsx index d3b1f69d7e6..d7c2bdb8e1a 100644 --- a/web/packages/agenta-entity-ui/src/drive/OriginTag.tsx +++ b/web/packages/agenta-entity-ui/src/drive/OriginTag.tsx @@ -18,14 +18,10 @@ export const ORIGIN_TIP: Record = { export const OriginTag = ({origin}: {origin: FileOrigin}) => ( - {origin === "agent" ? ( - - Agent - - ) : ( - - Session - - )} + {/* One quiet treatment for both scopes: a tinted Agent pill read as a status next to the + neutral Session one, when the two are just the halves of the same distinction. */} + + {origin === "agent" ? "Agent" : "Session"} + ) diff --git a/web/packages/agenta-shared/src/utils/shortcuts.ts b/web/packages/agenta-shared/src/utils/shortcuts.ts index e4e43171eb5..d9a04dcb974 100644 --- a/web/packages/agenta-shared/src/utils/shortcuts.ts +++ b/web/packages/agenta-shared/src/utils/shortcuts.ts @@ -40,6 +40,7 @@ export type ShortcutGroupId = | "run" | "composer" | "commands" + | "mentions" | "picker" | "approval" | "connection" @@ -54,6 +55,7 @@ export const SHORTCUT_GROUP_TITLES: Record = { run: "While the agent runs", composer: "Composer", commands: "The / menu", + mentions: "The @ file menu", picker: "Permission picker", approval: "Approval card", connection: "Connection dock", @@ -158,8 +160,9 @@ export const PLAYGROUND_SHORTCUTS: readonly Shortcut[] = [ {id: "composer.bold", group: "composer", label: "Bold", modifiers: ["mod"], key: "B"}, {id: "composer.italic", group: "composer", label: "Italic", modifiers: ["mod"], key: "I"}, {id: "composer.commands", group: "composer", label: "Open commands", key: "/"}, + {id: "composer.mentions", group: "composer", label: "Mention a file", key: "@"}, - // The / menu — SlashCommandPlugin.tsx. It binds no Home, End or ArrowLeft; those belong to + // The / menu — CommandPalettePlugin.tsx. It binds no Home, End or ArrowLeft; those belong to // the permission picker below, which is a different surface with its own key handler. { id: "commands.move", @@ -171,6 +174,23 @@ export const PLAYGROUND_SHORTCUTS: readonly Shortcut[] = [ {id: "commands.pick", group: "commands", label: "Pick the command", key: "↵", alt: {key: "⇥"}}, {id: "commands.dismiss", group: "commands", label: "Close the menu", key: "Esc"}, + // The @ file menu — the same plugin, with a folder level Tab enters and Esc steps back out of. + { + id: "mentions.move", + group: "mentions", + label: "Move through the list", + key: "↑", + alt: {key: "↓"}, + }, + {id: "mentions.pick", group: "mentions", label: "Reference the file or folder", key: "↵"}, + {id: "mentions.open", group: "mentions", label: "Open the folder", key: "⇥"}, + { + id: "mentions.back", + group: "mentions", + label: "Up one folder, then close the menu", + key: "Esc", + }, + // The permission picker the / menu opens — useRovingList.ts {id: "picker.move", group: "picker", label: "Move through the list", key: "↑", alt: {key: "↓"}}, { diff --git a/web/packages/agenta-ui/package.json b/web/packages/agenta-ui/package.json index 549a227d61b..c1de079820a 100644 --- a/web/packages/agenta-ui/package.json +++ b/web/packages/agenta-ui/package.json @@ -84,6 +84,7 @@ "@agenta/shared": "workspace:../agenta-shared", "@ant-design/icons": "^6.1.0", "@cloudflare/stream-react": "^1.9.3", + "@floating-ui/react": "^0.27.13", "@lexical/code": "^0.46.0", "@lexical/code-shiki": "^0.46.0", "@lexical/hashtag": "^0.46.0", @@ -144,7 +145,6 @@ "uuid": "^11.1.1" }, "devDependencies": { - "@floating-ui/react": "^0.27.13", "@phosphor-icons/core": "2.1.1", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", diff --git a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx index ed54dd01757..eb6a0dd7dfe 100644 --- a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx +++ b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react" @@ -33,18 +34,20 @@ import { type LexicalEditor, } from "lexical" +import type {PaletteSpec} from "./assets/palette" import type {SlashCommandSection} from "./assets/slashCommands" +import {slashPaletteSpec} from "./assets/slashPalette" import {chatInputTheme} from "./assets/theme" import {CHAT_TRANSFORMERS} from "./assets/transformers" import {CharacterCountPlugin} from "./plugins/CharacterCountPlugin" import {CodeFencePlugin} from "./plugins/CodeFencePlugin" +import {CommandPalettePlugin} from "./plugins/CommandPalettePlugin" import {beginDictation, type DictationSession} from "./plugins/dictation" import {EditableSyncPlugin} from "./plugins/EditableSyncPlugin" import {EditorRefBridge} from "./plugins/EditorRefBridge" import {FocusStatePlugin} from "./plugins/FocusStatePlugin" import {LinkPastePlugin} from "./plugins/LinkPastePlugin" import {SendButton} from "./plugins/SendButton" -import {SlashCommandPlugin} from "./plugins/SlashCommandPlugin" import {SubmitPlugin} from "./plugins/SubmitPlugin" /** Imperative handle for prefill / clear / focus (e.g. rewind-to-edit). */ @@ -122,6 +125,11 @@ export interface RichChatInputProps { * are untouched. See `assets/slashCommands`. */ slashCommands?: SlashCommandSection[] + /** + * The `@` file-mention palette. Built by the host (it needs drive data); omitted → no palette. + * Independent of `slashCommands`, so a surface can have either, both, or neither. + */ + filePalette?: PaletteSpec } // Static: RichText gives Cmd+B/I + block behavior, History gives undo/redo, list @@ -173,12 +181,13 @@ export const RichChatInput = forwardRef onChange, initialMarkdown, slashCommands, + filePalette, }, ref, ) { const editorRef = useRef(null) const dictationRef = useRef(null) - // The `/` palette spans this box and floats above it. + // The palettes span this box and float above it. const boxRef = useRef(null) const [focused, setFocused] = useState(false) // Resolved after mount: SSR has no platform, and answering during render would mismatch on @@ -187,6 +196,14 @@ export const RichChatInput = forwardRef useEffect(() => setModKey(modifierKeyLabel()), []) + // One plugin owns every trigger — see `CommandPalettePlugin` for why two would collide. + const palettes = useMemo(() => { + const specs: PaletteSpec[] = [] + if (slashCommands?.length) specs.push(slashPaletteSpec(slashCommands)) + if (filePalette) specs.push(filePalette) + return specs + }, [slashCommands, filePalette]) + // A send empties the editor, so the session must go with it: the recogniser flushes a last // final result on its way out, which would otherwise rebuild its nodes in the empty box. const handleSubmit = useCallback( @@ -396,9 +413,9 @@ export const RichChatInput = forwardRef {onChange ? : null} {/* Registers Enter above SubmitPlugin, so it must mount after it. */} - {slashCommands?.length ? ( - diff --git a/web/packages/agenta-ui/src/RichChatInput/assets/palette.ts b/web/packages/agenta-ui/src/RichChatInput/assets/palette.ts new file mode 100644 index 00000000000..a4e322ecb45 --- /dev/null +++ b/web/packages/agenta-ui/src/RichChatInput/assets/palette.ts @@ -0,0 +1,176 @@ +/** + * Palette types + run matching for the chat composer's trigger menus. + * + * ONE plugin drives every palette (`/` commands, `@` file mentions): two would race for Enter at + * CRITICAL by mount order, keep divergent dismissal latches, and clobber each other's + * `aria-activedescendant` on the single contenteditable root. + * + * Kept out of the plugin file so a host can import the types without pulling Lexical in. + */ +import type {ReactNode} from "react" + +/** + * What selecting an item does: drill into a picker the host owns, run a one-shot action, type text + * into the message, or move the palette somewhere else without closing it (`navigate` — entering a + * folder). `open` and `action` behave identically here; they differ only in what the footer promises. + */ +export type PaletteItemKind = "open" | "insert" | "action" | "navigate" + +/** + * How an `insert` reaches the message. `code` writes an inline-code text node rather than literal + * backticks: `$convertToMarkdownString` escapes a typed backtick in unformatted text, so a path + * written as plain text ships as `\`a/b.md\`` and never resolves to a file chip. + */ +export type PaletteInsertAs = "text" | "code" + +export interface PaletteItem { + key: string + /** Displayed and matched against — a command's label, or a file's path. */ + label: string + description?: string + /** Dim, after the label — the parent directory on a recents row. */ + secondary?: ReactNode + /** Right-aligned label — the current value for a command, size and age for a file. */ + tail?: ReactNode + icon?: ReactNode + kind: PaletteItemKind + /** `insert` items only: the text put into the message. Defaults to `label`. */ + insertText?: string + insertAs?: PaletteInsertAs + /** Runs after the menu closes, so a picker owns the keyboard. `navigate` runs without closing. */ + onSelect?: () => void + /** Tab (and the touch tap target) enters this item without closing the menu. */ + onDrillIn?: () => void +} + +export interface PaletteSection { + key: string + title: string + items: PaletteItem[] +} + +/** One palette the plugin can open, keyed on its trigger character. */ +export interface PaletteSpec { + /** Identity for the dismissal latch — an Escape in one palette must not suppress another. */ + key: string + trigger: string + /** May the query hold a `/`? File paths need it; a command palette must not, or `/a/b` keeps it open. */ + allowSlashInQuery: boolean + /** aria-label for the listbox. */ + label: string + sections: PaletteSection[] + /** `label` filters and ranks here; `none` means the host already did (search results). */ + filterMode: "label" | "none" + /** The run's query, or null when this palette is closed. Fires from an effect, once per change. */ + onQueryChange?: (query: string | null) => void + header?: ReactNode + /** The whole footer bar, as a function of the highlighted row. */ + footer?: (activeItem: PaletteItem | undefined) => ReactNode + /** Paint shimmer rows in place of the list; the keyboard stays live against `sections`. */ + loading?: boolean + emptyText?: (query: string) => ReactNode + /** Return true to CONSUME Escape (stepped back a level) — the plugin then neither closes nor latches. */ + onEscape?: () => boolean +} + +const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +/** + * A run: the trigger opening a block or following a space, plus the word being typed. The boundary + * is what keeps `and/or`, URLs, paths and `hey@agenta.ai` from opening a menu mid-sentence. + */ +export const runPatternFor = (trigger: string, allowSlash: boolean): RegExp => + new RegExp(`(^|\\s)${escapeRe(trigger)}(${allowSlash ? "[^\\s]*" : "[^\\s/]*"})$`) + +/** The run the caret sits in, located within its text. */ +export interface PaletteRun { + /** The typed word after the trigger. */ + query: string + /** Offset of the trigger within the text. */ + start: number + /** False when the trigger is flush against the text start — the caller decides if that opens one. */ + afterSpace: boolean +} + +/** The run ending at the caret for `pattern`, or null when there is none. */ +export function readRun(textUpToCaret: string, pattern: RegExp): PaletteRun | null { + const hit = pattern.exec(textUpToCaret) + if (!hit) return null + return {query: hit[2], start: hit.index + hit[1].length, afterSpace: hit[1] !== ""} +} + +/** + * Whether a run flush against the start of its own text node may still open the menu. + * + * `readRun` only sees one node's text, and formatting splits a paragraph into adjacent text nodes — + * so a bolded `/model` after a plain `hello ` starts its node while the message reads `hello /model`. + * Starting a NODE is not starting the MESSAGE: the run qualifies when everything before it in the + * block is empty or ends in whitespace, which is the same rule the regex applies inside a node. + */ +export const runFollowsBoundary = (textBefore: string): boolean => + textBefore === "" || /\s$/.test(textBefore) + +/** A located run — the identity a dismissal is keyed on. */ +export interface LocatedRun { + palette?: string + nodeKey: string + start: number +} + +/** + * Whether two runs are the same run. Dismissal is keyed on this — on PALETTE + POSITION, not on the + * run's text (a retyped trigger gives an identical query) and not on mere existence (that leaks the + * dismissal onto the next run, so a paste after an Escape would never open the menu). + */ +export const isSameRun = (a: LocatedRun | null, b: LocatedRun | null) => + !!a && !!b && a.nodeKey === b.nodeKey && a.start === b.start && a.palette === b.palette + +/** A label split around the matched query, for the in-name match highlight. */ +export interface LabelMatch { + before: string + match: string + after: string +} + +/** + * Where `query` matches inside `label`, ignoring a leading slash so `/mo` finds `/model` at the + * start rather than one character in. Case-insensitive substring; null when it does not match. + */ +export function matchLabel(label: string, query: string): LabelMatch | null { + const body = label.startsWith("/") ? label.slice(1) : label + const prefix = label.slice(0, label.length - body.length) + if (!query) return {before: label, match: "", after: ""} + const at = body.toLowerCase().indexOf(query.toLowerCase()) + if (at < 0) return null + return { + before: prefix + body.slice(0, at), + match: body.slice(at, at + query.length), + after: body.slice(at + query.length), + } +} + +/** + * Sections keeping only items that match, with empty sections dropped. Prefix matches sort above + * substring matches within a section so `/mo` puts `/model` over `/notion.move_page`. + */ +export function filterSections(sections: PaletteSection[], query: string): PaletteSection[] { + if (!query) return sections.filter((section) => section.items.length > 0) + const q = query.toLowerCase() + const rank = (item: PaletteItem) => { + const body = (item.label.startsWith("/") ? item.label.slice(1) : item.label).toLowerCase() + return body.startsWith(q) ? 0 : 1 + } + return sections + .map((section) => ({ + ...section, + items: section.items + .filter((item) => matchLabel(item.label, query) !== null) + .sort((a, b) => rank(a) - rank(b)), + })) + .filter((section) => section.items.length > 0) +} + +/** Every visible item in order, so arrow keys can walk the sections as one list. */ +export function flattenSections(sections: PaletteSection[]): PaletteItem[] { + return sections.flatMap((section) => section.items) +} diff --git a/web/packages/agenta-ui/src/RichChatInput/assets/slashCommands.ts b/web/packages/agenta-ui/src/RichChatInput/assets/slashCommands.ts index 31382c9ecd4..35b0eac3888 100644 --- a/web/packages/agenta-ui/src/RichChatInput/assets/slashCommands.ts +++ b/web/packages/agenta-ui/src/RichChatInput/assets/slashCommands.ts @@ -1,134 +1,25 @@ /** - * Slash-command types + matching for the chat composer's `/` palette. + * The `/` command palette's slice of the generic palette contract in `./palette`. * - * The composer is generic — a host supplies its own sections (the playground chat gives - * `/model` and the agent's tools and skills). Kept out of the plugin file so a host - * can import the types without pulling Lexical in. + * The composer is generic — a host supplies its own sections (the playground chat gives `/model` + * and the agent's tools and skills). Only the trigger and its run pattern live here; matching, + * filtering and the item shape are shared with the `@` file palette. */ -import type {ReactNode} from "react" +import {readRun, runPatternFor} from "./palette" -/** - * What selecting an item does: drill into a picker the host owns, run a one-shot action, or type - * text into the message. `open` and `action` behave identically here — the menu closes and the host's - * `onSelect` runs — they differ only in what the footer promises the next keystroke will do. - */ -export type SlashCommandKind = "open" | "insert" | "action" - -export interface SlashCommandItem { - key: string - /** Displayed and matched against, leading slash included (e.g. `/model`). */ - label: string - description?: string - /** Right-aligned label — the current value for a command, the type tag for a tool. */ - tail?: ReactNode - icon?: ReactNode - kind: SlashCommandKind - /** `insert` items only: the text typed into the message. Defaults to `label`. */ - insertText?: string - /** `open`/`action` items: runs after the menu closes, so the picker owns the keyboard. */ - onSelect?: () => void -} +export type {PaletteItemKind as SlashCommandKind} from "./palette" +export type {PaletteItem as SlashCommandItem} from "./palette" +export type {PaletteSection as SlashCommandSection} from "./palette" +export type {PaletteRun as CommandRun} from "./palette" +export type {LabelMatch} from "./palette" -export interface SlashCommandSection { - key: string - title: string - items: SlashCommandItem[] -} +export {filterSections, flattenSections, isSameRun, matchLabel, runFollowsBoundary} from "./palette" /** A command run: a `/` opening the block or following a space, plus the word being typed. */ -export const COMMAND_RUN = /(^|\s)\/([^\s/]*)$/ - -/** The run the caret sits in, located within its text node. */ -export interface CommandRun { - /** The typed word after the `/`. */ - query: string - /** Offset of the `/` within the text. */ - start: number - /** False when the `/` is flush against the text start — the caller decides if that opens one. */ - afterSpace: boolean -} +export const COMMAND_RUN = runPatternFor("/", false) /** * The command run ending at the caret, or null when there is none. Requiring a space (or the very * start) before the `/` is what keeps `and/or`, URLs, and paths from opening the menu mid-sentence. */ -export function readCommandRun(textUpToCaret: string): CommandRun | null { - const hit = COMMAND_RUN.exec(textUpToCaret) - if (!hit) return null - return {query: hit[2], start: hit.index + hit[1].length, afterSpace: hit[1] !== ""} -} - -/** - * Whether a run flush against the start of its own text node may still open the menu. - * - * `readCommandRun` only sees one node's text, and formatting splits a paragraph into adjacent text - * nodes — so a bolded `/model` after a plain `hello ` starts its node while the message reads - * `hello /model`. Starting a NODE is not starting the MESSAGE: the run qualifies when everything - * before it in the block is empty or ends in whitespace, which is the same rule the regex applies - * inside a single node. - */ -export const runFollowsBoundary = (textBefore: string): boolean => - textBefore === "" || /\s$/.test(textBefore) - -/** - * Whether two runs are the same run. Dismissal is keyed on this — on POSITION, not on the run's - * text (a retyped `/` gives an identical query) and not on mere existence (that leaks the dismissal - * onto the next run, so a paste after an Escape would never open the menu). - */ -export const isSameRun = ( - a: {nodeKey: string; start: number} | null, - b: {nodeKey: string; start: number} | null, -) => !!a && !!b && a.nodeKey === b.nodeKey && a.start === b.start - -/** A label split around the matched query, for the in-name match highlight. */ -export interface LabelMatch { - before: string - match: string - after: string -} - -/** - * Where `query` matches inside `label`, ignoring the leading slash so `/mo` finds `/model` at the - * start rather than one character in. Case-insensitive substring; null when it does not match. - */ -export function matchLabel(label: string, query: string): LabelMatch | null { - const body = label.startsWith("/") ? label.slice(1) : label - const prefix = label.slice(0, label.length - body.length) - if (!query) return {before: label, match: "", after: ""} - const at = body.toLowerCase().indexOf(query.toLowerCase()) - if (at < 0) return null - return { - before: prefix + body.slice(0, at), - match: body.slice(at, at + query.length), - after: body.slice(at + query.length), - } -} - -/** - * Sections keeping only items that match, with empty sections dropped. Prefix matches sort above - * substring matches within a section so `/mo` puts `/model` over `/notion.move_page`. - */ -export function filterSections( - sections: SlashCommandSection[], - query: string, -): SlashCommandSection[] { - if (!query) return sections.filter((section) => section.items.length > 0) - const q = query.toLowerCase() - const rank = (item: SlashCommandItem) => { - const body = (item.label.startsWith("/") ? item.label.slice(1) : item.label).toLowerCase() - return body.startsWith(q) ? 0 : 1 - } - return sections - .map((section) => ({ - ...section, - items: section.items - .filter((item) => matchLabel(item.label, query) !== null) - .sort((a, b) => rank(a) - rank(b)), - })) - .filter((section) => section.items.length > 0) -} - -/** Every visible item in order, so arrow keys can walk the sections as one list. */ -export function flattenSections(sections: SlashCommandSection[]): SlashCommandItem[] { - return sections.flatMap((section) => section.items) -} +export const readCommandRun = (textUpToCaret: string) => readRun(textUpToCaret, COMMAND_RUN) diff --git a/web/packages/agenta-ui/src/RichChatInput/assets/slashPalette.tsx b/web/packages/agenta-ui/src/RichChatInput/assets/slashPalette.tsx new file mode 100644 index 00000000000..b810ae427a2 --- /dev/null +++ b/web/packages/agenta-ui/src/RichChatInput/assets/slashPalette.tsx @@ -0,0 +1,34 @@ +/** + * The `/` command palette's spec — its trigger, its aria label, and the footer that names what + * Enter will do for the highlighted row. + */ +import {HintKey} from "../plugins/PalettePanel" + +import type {PaletteItem, PaletteSpec} from "./palette" +import type {SlashCommandSection} from "./slashCommands" + +const enterLabel = (item: PaletteItem | undefined) => { + if (!item) return "send" + if (item.kind === "open") return "open" + if (item.kind === "action") return "run" + return "insert" +} + +export const slashPaletteSpec = (sections: SlashCommandSection[]): PaletteSpec => ({ + key: "slash", + trigger: "/", + allowSlashInQuery: false, + label: "Commands", + sections, + filterMode: "label", + emptyText: (query) => `No command or skill matches “${query}”`, + footer: (activeItem) => ( + <> + + {/* Names what Enter actually does, including the empty state where the menu declines + it and the message sends. */} + + + + ), +}) diff --git a/web/packages/agenta-ui/src/RichChatInput/index.ts b/web/packages/agenta-ui/src/RichChatInput/index.ts index 6a6bbb9ba9d..9d0f11cfac4 100644 --- a/web/packages/agenta-ui/src/RichChatInput/index.ts +++ b/web/packages/agenta-ui/src/RichChatInput/index.ts @@ -4,3 +4,12 @@ export {RichChatInput, ShortcutHint} from "./RichChatInput" export type {RichChatInputProps, RichChatInputHandle} from "./RichChatInput" export {CHAT_TRANSFORMERS} from "./assets/transformers" export type {SlashCommandItem, SlashCommandKind, SlashCommandSection} from "./assets/slashCommands" +export {HintKey, PalettePanel} from "./plugins/PalettePanel" +export type {PalettePanelProps} from "./plugins/PalettePanel" +export type { + PaletteInsertAs, + PaletteItem, + PaletteItemKind, + PaletteSection, + PaletteSpec, +} from "./assets/palette" diff --git a/web/packages/agenta-ui/src/RichChatInput/plugins/CommandPalettePlugin.tsx b/web/packages/agenta-ui/src/RichChatInput/plugins/CommandPalettePlugin.tsx new file mode 100644 index 00000000000..08add731ac7 --- /dev/null +++ b/web/packages/agenta-ui/src/RichChatInput/plugins/CommandPalettePlugin.tsx @@ -0,0 +1,387 @@ +/** + * CommandPalettePlugin — the trigger menus above the composer (`/` commands, `@` file mentions). + * + * ONE plugin drives every palette. Two would each claim Enter at CRITICAL and race by mount order + * even while closed, keep divergent dismissal latches, and clobber each other's + * `aria-activedescendant` on the single contenteditable root. + * + * A palette opens on a trigger that starts a block or follows a space (so `and/or`, URLs, paths and + * `hey@agenta.ai` never trigger one), filters as the user types, and hands the selection back to the + * host: an `insert` item types its text into the message, an `open` item closes the menu first so the + * host's picker owns the keyboard, a `navigate` item moves the palette without closing it. + * + * The menu registers Enter at CRITICAL because `SubmitPlugin` claims it at HIGH — without that a + * selection would send the message. With no matches it deliberately declines Enter, so a message + * that merely starts with a trigger still sends. + */ +import {useCallback, useEffect, useId, useMemo, useRef, useState} from "react" + +import {autoUpdate, flip, offset, shift, size, useFloating} from "@floating-ui/react" +import {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext" +import { + $createTextNode, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_CRITICAL, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_TAB_COMMAND, + type LexicalNode, +} from "lexical" +import {createPortal} from "react-dom" + +import { + filterSections, + flattenSections, + isSameRun, + readRun, + runFollowsBoundary, + runPatternFor, + type LocatedRun, + type PaletteInsertAs, + type PaletteItem, + type PaletteSpec, +} from "../assets/palette" + +import {PalettePanel} from "./PalettePanel" + +interface CommandPalettePluginProps { + palettes: PaletteSpec[] + /** The composer box the menu spans and sits above. */ + anchorRef: React.RefObject + /** Suppresses the menu without unmounting it (e.g. while dictating). */ + disabled?: boolean +} + +/** Everything written before this node within its block, across formatting-split siblings. */ +const $textBeforeInBlock = (node: LexicalNode): string => { + let text = "" + for (let prev = node.getPreviousSibling(); prev; prev = prev.getPreviousSibling()) { + text = prev.getTextContent() + text + } + return text +} + +export function CommandPalettePlugin({palettes, anchorRef, disabled}: CommandPalettePluginProps) { + const [editor] = useLexicalComposerContext() + const [run, setRun] = useState<(LocatedRun & {query: string}) | null>(null) + const [activeIndex, setActiveIndex] = useState(0) + const activeRowRef = useRef(null) + // Which run was dismissed, and which the caret sits in now. Without the latch the next caret + // move re-derives the same run and the menu springs back, so Escape would read as broken. Keyed + // on PALETTE + POSITION, not on the run's text (a retyped trigger gives an identical query) and + // not on mere existence (that leaks the dismissal onto the next run). + const dismissedRef = useRef(null) + const runRef = useRef(null) + + const matchers = useMemo( + () => + palettes.map((spec) => ({ + spec, + pattern: runPatternFor(spec.trigger, spec.allowSlashInQuery), + })), + [palettes], + ) + const matchersRef = useRef(matchers) + matchersRef.current = matchers + + const active = run ? palettes.find((p) => p.key === run.palette) : undefined + const open = !!run && !!active && !disabled + const query = run?.query ?? "" + + const visibleSections = useMemo(() => { + if (!open || !active) return [] + return active.filterMode === "label" + ? filterSections(active.sections, query) + : active.sections + }, [open, active, query]) + const items = useMemo(() => flattenSections(visibleSections), [visibleSections]) + const activeItem = items[activeIndex] + + const listId = useId() + const optionId = useCallback((index: number) => `${listId}-opt-${index}`, [listId]) + + const close = useCallback(() => { + dismissedRef.current = runRef.current + setRun(null) + }, []) + + // Read the caret's run on every edit. Requiring a space (or the block start) before the trigger + // is what keeps `and/or`, URLs, paths and email addresses from opening a menu mid-sentence. + useEffect(() => { + /** The run at the caret, or null when the caret isn't in one. */ + const $readCaretRun = () => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null + const node = selection.anchor.getNode() + // An emptied paragraph anchors on the element itself, not a text node. + if (!$isTextNode(node)) return null + const upToCaret = node.getTextContent().slice(0, selection.anchor.offset) + const before = $textBeforeInBlock(node) + let best: (LocatedRun & {query: string}) | null = null + for (const {spec, pattern} of matchersRef.current) { + const hit = readRun(upToCaret, pattern) + if (!hit) continue + // A run flush against the node start has to be judged against the rest of the + // block, not the node: formatting splits a paragraph into siblings. + if (!hit.afterSpace && !runFollowsBoundary(before)) continue + if (!best || hit.start > best.start) { + best = { + palette: spec.key, + query: hit.query, + nodeKey: node.getKey(), + start: hit.start, + } + } + } + return best + } + return editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + const next = $readCaretRun() + runRef.current = next + // Every path funnels through here: moving to a different run (or none) re-arms the + // menu, editing within the dismissed one does not. An early return above would + // strand the latch on. + const suppressed = isSameRun(next, dismissedRef.current) + if (!suppressed) dismissedRef.current = null + setRun(!next || suppressed ? null : next) + }) + }) + }, [editor]) + + // Report the query to whichever host owns this palette's data. Guarded on the last VALUE + // emitted: a host that rebuilds its spec in response would otherwise feed this effect a new + // identity and have its own query echoed straight back at it. + const emittedRef = useRef<{palette: string; query: string | null}>({palette: "", query: null}) + useEffect(() => { + const next = {palette: run?.palette ?? "", query: run?.query ?? null} + if (emittedRef.current.palette === next.palette && emittedRef.current.query === next.query) + return + emittedRef.current = next + for (const {spec} of matchersRef.current) { + spec.onQueryChange?.(spec.key === next.palette ? next.query : null) + } + }, [run]) + + // A changed result set re-homes the highlight on the first row, so the footer's Enter hint and + // the highlight never describe a row that filtering just removed. + useEffect(() => { + setActiveIndex(0) + }, [run?.palette, run?.query]) + + useEffect(() => { + if (activeIndex > 0 && activeIndex >= items.length) setActiveIndex(0) + }, [activeIndex, items.length]) + + /** + * Swap the run the caret sits in for `text`. EVERY kind goes through this — an `insert` puts its + * slug there, an `open`/`action` puts nothing — because the surrounding message must survive + * either way, now that a run can start mid-sentence. The host must not clear the composer + * instead: `hello /model` would lose `hello`. + * + * `as: "code"` writes an inline-code node rather than literal backticks: `$convertToMarkdownString` + * escapes a typed backtick in unformatted text, so a path written as plain text would ship as + * `\`a/b.md\`` and never resolve to a file chip. + */ + const replaceRun = useCallback( + (text: string, as: PaletteInsertAs = "text") => { + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + const node = selection.anchor.getNode() + if (!$isTextNode(node)) return + const full = node.getTextContent() + const caret = selection.anchor.offset + const matcher = matchersRef.current.find( + (m) => m.spec.key === runRef.current?.palette, + ) + const hit = matcher ? readRun(full.slice(0, caret), matcher.pattern) : null + const start = hit ? hit.start : caret + const tail = full.slice(caret) + // One separator, never two: a run replaced mid-sentence already has whitespace + // after it. + const pad = text !== "" && !/^\s/.test(tail) + if (as === "text") { + const upToCaret = full.slice(0, start) + text + (pad ? " " : "") + node.setTextContent(upToCaret + tail) + node.select(upToCaret.length, upToCaret.length) + return + } + // The spacer is not optional here: it is what keeps the caret out of the code span, + // so a run that already has whitespace after it gives up that one character instead + // of ending with two. + node.setTextContent(full.slice(0, start) + (pad ? tail : tail.slice(1))) + const code = $createTextNode(text) + code.setFormat("code") + // Spliced by hand rather than through `insertNodes`, which folds the spacer into + // the span. + const spacer = $createTextNode(" ") + if (start === 0) node.insertBefore(code) + else if (start >= node.getTextContentSize()) node.insertAfter(code) + else node.splitText(start)[0].insertAfter(code) + code.insertAfter(spacer) + spacer.select(1, 1) + }) + }, + [editor], + ) + + const select = useCallback( + (item: PaletteItem) => { + // `navigate` moves the palette (into a folder); the run and the menu both stay put. + if (item.kind === "navigate") { + item.onSelect?.() + return + } + const as = item.kind === "insert" ? (item.insertAs ?? "text") : "text" + const text = item.kind === "insert" ? (item.insertText ?? item.label) : "" + replaceRun(text, as) + close() + if (item.kind !== "insert") item.onSelect?.() + }, + [close, replaceRun], + ) + + const drillIn = useCallback( + (item: PaletteItem) => { + if (!item.onDrillIn) { + select(item) + return + } + item.onDrillIn() + // Entering a level clears what was typed, so the new level lists rather than filters. + // Rewriting the run to the bare trigger leaves `start` where it was, so the dismissal + // latch still names the same run. + if (active) replaceRun(active.trigger, "text") + setActiveIndex(0) + }, + [active, replaceRun, select], + ) + + // Keyboard. Registered above SubmitPlugin's HIGH so a selection never leaks through as a send. + useEffect(() => { + if (!open) return + // preventDefault too: returning true only stops Lexical, the caret still moves natively. + const move = (event: KeyboardEvent | null, delta: number) => { + if (!items.length) return false + event?.preventDefault() + setActiveIndex((i) => (i + delta + items.length) % items.length) + requestAnimationFrame(() => activeRowRef.current?.scrollIntoView({block: "nearest"})) + return true + } + const unregister = [ + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (event) => move(event, 1), + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event) => move(event, -1), + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + // A palette that stepped back a level consumes Escape without closing — and + // without latching, so the run stays live for the next press. + if (active?.onEscape?.()) return true + close() + return true + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + // Nothing matched: decline, so SubmitPlugin sends the text as written. + if (!activeItem) return false + event?.preventDefault() + select(activeItem) + return true + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + KEY_TAB_COMMAND, + (event) => { + if (!activeItem) return false + event?.preventDefault() + drillIn(activeItem) + return true + }, + COMMAND_PRIORITY_CRITICAL, + ), + ] + return () => unregister.forEach((fn) => fn()) + }, [active, activeItem, close, drillIn, editor, items.length, open, select]) + + // The menu spans the composer and sits above it, so it reads as part of the input rather than a + // dropdown hanging off the caret. + const {refs, floatingStyles} = useFloating({ + open, + placement: "top-start", + middleware: [ + offset(8), + flip({fallbackPlacements: ["bottom-start"]}), + shift({padding: 8}), + size({ + apply({rects, elements}) { + elements.floating.style.width = `${rects.reference.width}px` + }, + }), + ], + whileElementsMounted: autoUpdate, + }) + + useEffect(() => { + refs.setReference(open ? anchorRef.current : null) + }, [anchorRef, open, refs]) + + // Focus never leaves the editor while the palette is up, so the editor is what must name the + // active option — without this the listbox is inert to a screen reader. + useEffect(() => { + const root = editor.getRootElement() + if (!root) return + const clear = () => { + root.removeAttribute("aria-controls") + root.removeAttribute("aria-activedescendant") + } + if (!open) { + clear() + return + } + root.setAttribute("aria-controls", listId) + if (activeItem) root.setAttribute("aria-activedescendant", optionId(activeIndex)) + else root.removeAttribute("aria-activedescendant") + return clear + }, [activeIndex, activeItem, editor, listId, open, optionId]) + + if (!open || !active) return null + + return createPortal( + , + document.body, + ) +} diff --git a/web/packages/agenta-ui/src/RichChatInput/plugins/PalettePanel.tsx b/web/packages/agenta-ui/src/RichChatInput/plugins/PalettePanel.tsx new file mode 100644 index 00000000000..bb9605226ef --- /dev/null +++ b/web/packages/agenta-ui/src/RichChatInput/plugins/PalettePanel.tsx @@ -0,0 +1,266 @@ +/** + * The floating palette panel — presentation only, so both the `/` command menu and the `@` file + * menu paint identically and a story can drive every state without Lexical. + */ +import type {CSSProperties, ReactNode, RefObject} from "react" + +import {CaretRight} from "@phosphor-icons/react" +import clsx from "clsx" + +import {matchLabel, type PaletteItem, type PaletteSection} from "../assets/palette" + +export interface PalettePanelProps { + listId: string + label: string + query: string + sections: PaletteSection[] + activeIndex: number + activeRowRef: RefObject + optionId: (index: number) => string + onHover: (index: number) => void + onSelect: (item: PaletteItem) => void + onDrillIn: (item: PaletteItem) => void + header?: ReactNode + footer?: ReactNode + loading?: boolean + emptyText?: ReactNode + floatingRef: (node: HTMLElement | null) => void + floatingStyles: CSSProperties +} + +export function PalettePanel({ + listId, + label, + query, + sections, + activeIndex, + activeRowRef, + optionId, + onHover, + onSelect, + onDrillIn, + header, + footer, + loading, + emptyText, + floatingRef, + floatingStyles, +}: PalettePanelProps) { + let rowIndex = -1 + const isEmpty = sections.every((section) => section.items.length === 0) + + return ( +
, escaping the app font scope (preflight off). + // box-border for the same reason: `size()` sets the ANCHOR's width, and with no + // preflight reset the default content-box would add the 1px borders on top, leaving the + // menu wider than the composer and nudged off-anchor by `shift`. + className="z-[1050] box-border overflow-hidden rounded-[10px] border border-solid border-[var(--ag-colorBorderSecondary)] bg-[var(--ag-colorBgElevated)] font-portal shadow-overlay" + > + {header ? ( +
+ {header} +
+ ) : null} + {/* ~8 rows: enough to browse a level, short enough to leave the transcript readable. */} +
+ {isEmpty && loading ? ( + + ) : isEmpty ? ( +
+
+ {emptyText ?? `No match for “${query}”`} +
+
+ ) : ( + sections.map((section) => ( +
+ {section.title ? ( +
+ {section.title} +
+ ) : null} + {section.items.map((item) => { + rowIndex += 1 + const index = rowIndex + const active = index === activeIndex + return ( + onHover(index)} + onSelect={() => onSelect(item)} + onDrillIn={() => onDrillIn(item)} + /> + ) + })} +
+ )) + )} + {/* Rows are already listed, but a deeper level is still arriving. */} + {!isEmpty && loading ? : null} +
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+ ) +} + +// Uneven widths so the placeholder reads as a list of names, not a progress bar. +const SHIMMER_WIDTHS = ["w-1/2", "w-1/3", "w-3/5"] + +function ShimmerRows({rows = 3}: {rows?: number}) { + return ( + <> + {SHIMMER_WIDTHS.slice(0, rows).map((width) => ( +
+ + +
+ ))} + + ) +} + +function PaletteRow({ + item, + id, + active, + rowRef, + query, + onHover, + onSelect, + onDrillIn, +}: { + item: PaletteItem + id: string + active: boolean + rowRef?: RefObject + query: string + onHover: () => void + onSelect: () => void + onDrillIn: () => void +}) { + const parts = matchLabel(item.label, query) + return ( +
{ + if (e.button !== 0) return + e.preventDefault() + onSelect() + }} + className={clsx( + "mx-1.5 flex cursor-pointer items-center gap-2.5 rounded-md px-[9px] py-1.5", + active && "bg-[var(--ag-colorFillTertiary)]", + )} + > + {/* No reserved slot when nothing supplies an icon — an empty one just reads as a + ragged left margin. */} + {item.icon ? ( + + {item.icon} + + ) : null} + + {parts ? ( + <> + {parts.before} + {parts.match ? ( + // A primary tint, not colorInfoBg — that token sits within a hair of + // the row background, so the match read as unmarked. + + {parts.match} + + ) : null} + {parts.after} + + ) : ( + item.label + )} + + {item.secondary ? ( + + {item.secondary} + + ) : null} + {item.description ? ( + + {item.description} + + ) : null} + {/* A row's last mark lands on the gutter either way — the caret on a folder row, the + meta text on a plain one — so the list reads with one right edge. */} + {item.onDrillIn ? ( + // A real target, because a touch screen has no Tab. stopPropagation keeps the tap + // off the row's own reference action. + + ) : ( + + {item.tail} + + )} +
+ ) +} + +/** One `key + label` footer hint, e.g. `↵ reference`. */ +export function HintKey({keys, label}: {keys: string; label: string}) { + return ( + + + {keys} + + {label} + + ) +} diff --git a/web/packages/agenta-ui/src/RichChatInput/plugins/SlashCommandPlugin.tsx b/web/packages/agenta-ui/src/RichChatInput/plugins/SlashCommandPlugin.tsx deleted file mode 100644 index b95f1391254..00000000000 --- a/web/packages/agenta-ui/src/RichChatInput/plugins/SlashCommandPlugin.tsx +++ /dev/null @@ -1,404 +0,0 @@ -/** - * SlashCommandPlugin — the `/` command palette above the composer. - * - * Opens on a `/` that starts a block or follows a space (so `and/or`, URLs, and paths never trigger - * it), filters as the user types, and hands the selection back to the host: an `insert` item types - * its text into the message, an `open` item closes the menu first so the host's picker owns the - * keyboard. - * - * The menu registers Enter at CRITICAL because `SubmitPlugin` claims it at HIGH — without that a - * selection would send the message. With no matches it deliberately declines Enter, so a message - * that merely starts with a slash still sends. - */ -import {useCallback, useEffect, useId, useMemo, useRef, useState} from "react" - -import {autoUpdate, flip, offset, shift, size, useFloating} from "@floating-ui/react" -import {useLexicalComposerContext} from "@lexical/react/LexicalComposerContext" -import clsx from "clsx" -import { - $getSelection, - $isRangeSelection, - $isTextNode, - COMMAND_PRIORITY_CRITICAL, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_ENTER_COMMAND, - KEY_ESCAPE_COMMAND, - KEY_TAB_COMMAND, - type LexicalNode, -} from "lexical" -import {createPortal} from "react-dom" - -import { - filterSections, - flattenSections, - isSameRun, - matchLabel, - readCommandRun, - runFollowsBoundary, - type SlashCommandItem, - type SlashCommandSection, -} from "../assets/slashCommands" - -interface SlashCommandPluginProps { - sections: SlashCommandSection[] - /** The composer box the menu spans and sits above. */ - anchorRef: React.RefObject - /** Suppresses the menu without unmounting it (e.g. while dictating). */ - disabled?: boolean -} - -/** A run plus the text node it lives in — the identity a dismissal is keyed on. */ -interface LocatedRun { - query: string - nodeKey: string - start: number -} - -/** Everything written before this node within its block, across formatting-split siblings. */ -const $textBeforeInBlock = (node: LexicalNode): string => { - let text = "" - for (let prev = node.getPreviousSibling(); prev; prev = prev.getPreviousSibling()) { - text = prev.getTextContent() + text - } - return text -} - -export function SlashCommandPlugin({sections, anchorRef, disabled}: SlashCommandPluginProps) { - const [editor] = useLexicalComposerContext() - const [query, setQuery] = useState(null) - const [activeIndex, setActiveIndex] = useState(0) - const activeRowRef = useRef(null) - // Which run was dismissed, and which the caret sits in now. Without the latch the next caret - // move re-derives the same run and the menu springs back, so Escape would read as broken. Keyed - // on POSITION, not on the run's text (a retyped `/` gives an identical query) and not on mere - // existence (that leaks the dismissal onto the next run — see `CommandRun`). - const dismissedRef = useRef(null) - const runRef = useRef(null) - - const open = query !== null && !disabled - - const visibleSections = useMemo( - () => (open ? filterSections(sections, query ?? "") : []), - [open, sections, query], - ) - const items = useMemo(() => flattenSections(visibleSections), [visibleSections]) - const activeItem = items[activeIndex] - - const listId = useId() - const optionId = useCallback((index: number) => `${listId}-opt-${index}`, [listId]) - - const close = useCallback(() => { - dismissedRef.current = runRef.current - setQuery(null) - }, []) - - // Read the caret's command run on every edit. Requiring a space (or the block start) before the - // `/` is what keeps `and/or`, URLs, and paths from opening the menu mid-sentence. - useEffect(() => { - /** The command run at the caret, or null when the caret isn't in one. */ - const $readRun = (): LocatedRun | null => { - const selection = $getSelection() - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null - const node = selection.anchor.getNode() - // An emptied paragraph anchors on the element itself, not a text node. - if (!$isTextNode(node)) return null - const run = readCommandRun(node.getTextContent().slice(0, selection.anchor.offset)) - if (!run) return null - // A run flush against the node start has to be judged against the rest of the block, - // not the node: formatting splits a paragraph into siblings. - if (!run.afterSpace && !runFollowsBoundary($textBeforeInBlock(node))) return null - return {query: run.query, nodeKey: node.getKey(), start: run.start} - } - return editor.registerUpdateListener(({editorState}) => { - editorState.read(() => { - const next = $readRun() - runRef.current = next - // Every path funnels through here: moving to a different run (or none) re-arms the - // menu, editing within the dismissed one does not. An early return above would - // strand the latch on. - const suppressed = isSameRun(next, dismissedRef.current) - if (!suppressed) dismissedRef.current = null - setQuery(!next || suppressed ? null : next.query) - }) - }) - }, [editor]) - - // A changed result set re-homes the highlight on the first row, so the footer's Enter hint and - // the highlight never describe a row that filtering just removed. - useEffect(() => { - setActiveIndex(0) - }, [query]) - - useEffect(() => { - if (activeIndex > 0 && activeIndex >= items.length) setActiveIndex(0) - }, [activeIndex, items.length]) - - /** - * Swap the run the caret sits in for `text`. EVERY kind goes through this — an `insert` puts its - * slug there, an `open`/`action` puts nothing — because the surrounding message must survive - * either way, now that a run can start mid-sentence. The host must not clear the composer - * instead: `hello /model` would lose `hello`. - */ - const replaceRun = useCallback( - (text: string) => { - editor.update(() => { - const selection = $getSelection() - if (!$isRangeSelection(selection)) return - const node = selection.anchor.getNode() - if (!$isTextNode(node)) return - const full = node.getTextContent() - const caret = selection.anchor.offset - const run = readCommandRun(full.slice(0, caret)) - const head = full.slice(0, run ? run.start : caret) - const upToCaret = head + text - node.setTextContent(upToCaret + full.slice(caret)) - node.select(upToCaret.length, upToCaret.length) - }) - }, - [editor], - ) - - const select = useCallback( - (item: SlashCommandItem) => { - replaceRun(item.kind === "insert" ? `${item.insertText ?? item.label} ` : "") - close() - if (item.kind !== "insert") item.onSelect?.() - }, - [close, replaceRun], - ) - - // Keyboard. Registered above SubmitPlugin's HIGH so a selection never leaks through as a send. - useEffect(() => { - if (!open) return - // preventDefault too: returning true only stops Lexical, the caret still moves natively. - const move = (event: KeyboardEvent | null, delta: number) => { - if (!items.length) return false - event?.preventDefault() - setActiveIndex((i) => (i + delta + items.length) % items.length) - requestAnimationFrame(() => activeRowRef.current?.scrollIntoView({block: "nearest"})) - return true - } - const unregister = [ - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (event) => move(event, 1), - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (event) => move(event, -1), - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerCommand( - KEY_ESCAPE_COMMAND, - () => { - close() - return true - }, - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - // Nothing matched: decline, so SubmitPlugin sends the text as written. - if (!activeItem) return false - event?.preventDefault() - select(activeItem) - return true - }, - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerCommand( - KEY_TAB_COMMAND, - (event) => { - if (!activeItem) return false - event?.preventDefault() - select(activeItem) - return true - }, - COMMAND_PRIORITY_CRITICAL, - ), - ] - return () => unregister.forEach((fn) => fn()) - }, [activeItem, close, editor, items.length, open, select]) - - // The menu spans the composer and sits above it, so it reads as part of the input rather than a - // dropdown hanging off the caret. - const {refs, floatingStyles} = useFloating({ - open, - placement: "top-start", - middleware: [ - offset(8), - flip({fallbackPlacements: ["bottom-start"]}), - shift({padding: 8}), - size({ - apply({rects, elements}) { - elements.floating.style.width = `${rects.reference.width}px` - }, - }), - ], - whileElementsMounted: autoUpdate, - }) - - useEffect(() => { - refs.setReference(open ? anchorRef.current : null) - }, [anchorRef, open, refs]) - - // Focus never leaves the editor while the palette is up, so the editor is what must name the - // active option — without this the listbox is inert to a screen reader. - useEffect(() => { - const root = editor.getRootElement() - if (!root) return - const clear = () => { - root.removeAttribute("aria-controls") - root.removeAttribute("aria-activedescendant") - } - if (!open) { - clear() - return - } - root.setAttribute("aria-controls", listId) - if (activeItem) root.setAttribute("aria-activedescendant", optionId(activeIndex)) - else root.removeAttribute("aria-activedescendant") - return clear - }, [activeIndex, activeItem, editor, listId, open, optionId]) - - if (!open) return null - - let rowIndex = -1 - - return createPortal( -
, escaping the app font scope (preflight off). - // box-border for the same reason: `size()` sets the ANCHOR's width, and with no - // preflight reset the default content-box would add the 1px borders on top, leaving the - // menu wider than the composer and nudged off-anchor by `shift`. - className="z-[1050] box-border overflow-hidden rounded-[10px] border border-solid border-[var(--ag-colorBorderSecondary)] bg-[var(--ag-colorBgElevated)] font-portal shadow-overlay" - > -
- {items.length === 0 ? ( -
-
- No command or skill matches “{query}” -
-
- ) : ( - visibleSections.map((section) => ( -
-
- {section.title} -
- {section.items.map((item) => { - rowIndex += 1 - const index = rowIndex - const active = index === activeIndex - const parts = matchLabel(item.label, query ?? "") - return ( -
setActiveIndex(index)} - // mousedown, not click: the editor must not lose the caret - // before the selection runs. - onMouseDown={(e) => { - e.preventDefault() - select(item) - }} - className={clsx( - "mx-1.5 flex cursor-pointer items-center gap-2.5 rounded-md px-[9px] py-1.5", - active && "bg-[var(--ag-colorFillTertiary)]", - )} - > - {/* No reserved slot when nothing supplies an icon — an - empty one just reads as a ragged left margin. */} - {item.icon ? ( - - {item.icon} - - ) : null} - - {parts ? ( - <> - {parts.before} - {parts.match ? ( - // A primary tint, not colorInfoBg — that - // token sits within a hair of the row - // background, so the match read as unmarked. - - {parts.match} - - ) : null} - {parts.after} - - ) : ( - item.label - )} - - {item.description ? ( - - {item.description} - - ) : null} - {item.tail ? ( - - {item.tail} - - ) : null} -
- ) - })} -
- )) - )} -
-
- - {/* Names what Enter actually does, including the empty state where the menu - declines it and the message sends. */} - - -
-
, - document.body, - ) -} - -function HintKey({keys, label}: {keys: string; label: string}) { - return ( - - - {keys} - - {label} - - ) -} diff --git a/web/packages/agenta-ui/tests/unit/filePaletteInsert.render.test.tsx b/web/packages/agenta-ui/tests/unit/filePaletteInsert.render.test.tsx new file mode 100644 index 00000000000..a927633b1d7 --- /dev/null +++ b/web/packages/agenta-ui/tests/unit/filePaletteInsert.render.test.tsx @@ -0,0 +1,86 @@ +/** + * @vitest-environment jsdom + * + * What an `@` mention actually ships. + * + * `$convertToMarkdownString` escapes a backtick typed as ordinary text, so a path inserted as + * plain characters would leave the composer as \`a/b.md\` and never resolve to a file chip. The + * insert has to write an inline-code node instead, and the caret must not stay inside it. + */ +import {act, cleanup, render} from "@testing-library/react" +import {createRef} from "react" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {RichChatInput, type RichChatInputHandle} from "../../src/RichChatInput" +import type {PaletteSpec} from "../../src/RichChatInput/assets/palette" + +afterEach(cleanup) + +const filePalette: PaletteSpec = { + key: "files", + trigger: "@", + allowSlashInQuery: true, + label: "Files", + filterMode: "none", + sections: [ + { + key: "root", + title: "Root", + items: [ + { + key: "report", + label: "slop-report.md", + kind: "insert", + insertText: "audits/2026-08/slop-report.md", + insertAs: "code", + }, + ], + }, + ], +} + +const setup = () => { + const onSubmit = vi.fn() + const ref = createRef() + const view = render() + return {onSubmit, ref, view} +} + +const pickFirstRow = async () => { + const row = document.querySelector('[role="option"]') + expect(row).not.toBeNull() + await act(async () => { + row?.dispatchEvent(new MouseEvent("mousedown", {bubbles: true, cancelable: true})) + }) +} + +describe("the @ palette's insertion", () => { + it("ships the path as inline code, not as escaped backticks", async () => { + const {onSubmit, ref, view} = setup() + + await act(async () => { + ref.current?.focus() + ref.current?.insertText("Compare @") + }) + await pickFirstRow() + + const send = view.container.querySelector('button[aria-label="Send"]') + await act(async () => send?.click()) + expect(onSubmit).toHaveBeenCalledWith("Compare `audits/2026-08/slop-report.md`") + }) + + it("leaves the caret outside the code span, so the next word is plain text", async () => { + const {onSubmit, ref, view} = setup() + + await act(async () => { + ref.current?.focus() + ref.current?.insertText("@") + }) + await pickFirstRow() + await act(async () => ref.current?.insertText("now")) + + const send = view.container.querySelector('button[aria-label="Send"]') + await act(async () => send?.click()) + expect(onSubmit).toHaveBeenCalledWith("`audits/2026-08/slop-report.md` now") + }) +}) diff --git a/web/packages/agenta-ui/tests/unit/paletteRun.test.ts b/web/packages/agenta-ui/tests/unit/paletteRun.test.ts new file mode 100644 index 00000000000..513aca9e810 --- /dev/null +++ b/web/packages/agenta-ui/tests/unit/paletteRun.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, it} from "vitest" + +import {isSameRun, readRun, runPatternFor} from "../../src/RichChatInput/assets/palette" + +const FILE_RUN = runPatternFor("@", true) +const COMMAND_RUN = runPatternFor("/", false) + +const readFile = (text: string) => readRun(text, FILE_RUN) + +describe("runPatternFor('@')", () => { + it("opens on a bare `@` starting the text", () => { + expect(readFile("@")).toEqual({query: "", start: 0, afterSpace: false}) + }) + + it("opens on an `@` following a space, mid-message", () => { + expect(readFile("summarise @guide")).toEqual({query: "guide", start: 10, afterSpace: true}) + }) + + it("keeps a path in one run, unlike the `/` palette", () => { + expect(readFile("@docs/guide")?.query).toBe("docs/guide") + expect(readRun("/docs/guide", COMMAND_RUN)).toBeNull() + }) + + it("stays shut on an email address mid-sentence", () => { + expect(readFile("email me at hey@agenta.ai")).toBeNull() + }) + + it("closes once the run ends in whitespace", () => { + expect(readFile("@guide ")).toBeNull() + }) + + it("reports the last run when the caret sits after a second trigger", () => { + expect(readFile("@one @two")).toEqual({query: "two", start: 5, afterSpace: true}) + }) +}) + +describe("isSameRun", () => { + it("separates the same position in two different palettes", () => { + expect( + isSameRun( + {palette: "slash", nodeKey: "1", start: 0}, + {palette: "files", nodeKey: "1", start: 0}, + ), + ).toBe(false) + }) + + it("matches the same position in the same palette", () => { + expect( + isSameRun( + {palette: "files", nodeKey: "1", start: 4}, + {palette: "files", nodeKey: "1", start: 4}, + ), + ).toBe(true) + }) +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ffb9c8a6e1b..117f32d2cd1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -2165,6 +2165,9 @@ importers: '@cloudflare/stream-react': specifier: ^1.9.3 version: 1.9.3(react@19.2.6) + '@floating-ui/react': + specifier: ^0.27.13 + version: 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/code': specifier: ^0.46.0 version: 0.46.0(typescript@5.9.3) @@ -2349,9 +2352,6 @@ importers: specifier: ^11.1.1 version: 11.1.1 devDependencies: - '@floating-ui/react': - specifier: ^0.27.13 - version: 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@phosphor-icons/core': specifier: 2.1.1 version: 2.1.1 diff --git a/web/storybook/stories/domain/RichChatInput.stories.tsx b/web/storybook/stories/domain/RichChatInput.stories.tsx index e7f0c83cd7b..1b4f41b4eeb 100644 --- a/web/storybook/stories/domain/RichChatInput.stories.tsx +++ b/web/storybook/stories/domain/RichChatInput.stories.tsx @@ -7,12 +7,28 @@ import { } from "@agenta/entity-ui/drill-in" import PermissionsPickerPanel from "@agenta/oss/src/components/AgentChatSlice/components/SlashCommand/PermissionsPickerPanel" import { + HintKey, + PalettePanel, RichChatInput, + type PaletteItem, + type PalettePanelProps, + type PaletteSpec, type RichChatInputHandle, type SlashCommandSection, } from "@agenta/ui/rich-chat-input" import {SelectLLMProviderBase} from "@agenta/ui/select-llm-provider" -import {ChatCircleDots, Cpu, GraduationCap, Paperclip, ShieldCheck} from "@phosphor-icons/react" +import { + ChatCircleDots, + CircleNotch, + Cpu, + FileText, + FolderOpen, + FolderSimple, + GraduationCap, + MagnifyingGlass, + Paperclip, + ShieldCheck, +} from "@phosphor-icons/react" import type {Meta, StoryObj} from "@storybook/nextjs" /** @@ -335,3 +351,246 @@ export const SlashCommands: Story = { return }, } + +const FILE_ROWS: {path: string; folder?: boolean; tail: string}[] = [ + {path: "agent-files", folder: true, tail: "12 items"}, + {path: "audits", folder: true, tail: "4 items"}, + {path: "AGENTS.md", tail: "2.3 KB · 3d ago"}, + {path: "README.md", tail: "293 B · 4d ago"}, +] + +const fileItem = ( + row: {path: string; folder?: boolean; tail: string}, + onDrillIn?: () => void, +): PaletteItem => ({ + key: row.path, + label: row.folder ? `${row.path}/` : row.path, + icon: row.folder ? : , + tail: row.tail, + kind: "insert", + insertText: row.folder ? `${row.path}/` : row.path, + insertAs: "code", + onDrillIn: row.folder ? onDrillIn : undefined, +}) + +const filesHints = (activeItem: PaletteItem | undefined, inFolder?: string) => ( + <> + + + {activeItem?.onDrillIn ? : null} + + {inFolder ? ( + + searching inside {inFolder}/ + + ) : null} + +) + +const MOCK_DRIVE = [ + "AGENTS.md", + "README.md", + "audits/2026-08/slop-report.md", + "audits/2026-08/findings.json", + "agent-files/notes.md", +] + +/** A live `@` palette over a fixed file list — type to filter, Tab to enter a folder, Esc to back out. */ +const LiveFileMentions = () => { + const [last, setLast] = useState("") + const [query, setQuery] = useState(null) + const [cwd, setCwd] = useState("") + + const prefix = cwd ? `${cwd}/` : "" + const rows = (() => { + if (query) { + return MOCK_DRIVE.filter((p) => p.startsWith(prefix) && p.includes(query)).map((p) => ({ + path: p, + folder: false, + tail: "4.8 KB", + })) + } + const seen = new Map() + for (const p of MOCK_DRIVE) { + if (!p.startsWith(prefix)) continue + const rest = p.slice(prefix.length) + const cut = rest.indexOf("/") + seen.set(prefix + (cut < 0 ? rest : rest.slice(0, cut)), cut >= 0) + } + return [...seen].map(([path, folder]) => ({path, folder, tail: folder ? "open" : "4.8 KB"})) + })() + + const spec: PaletteSpec = { + key: "files", + trigger: "@", + allowSlashInQuery: true, + label: "Files", + filterMode: "none", + onQueryChange: (next) => { + setQuery(next) + if (next === null) setCwd("") + }, + onEscape: () => { + if (!cwd) return false + setCwd(cwd.includes("/") ? cwd.slice(0, cwd.lastIndexOf("/")) : "") + return true + }, + sections: rows.length + ? [ + { + key: "rows", + title: cwd || (query ? "Matches" : "Root"), + items: rows.map((row) => fileItem(row, () => setCwd(row.path))), + }, + ] + : [], + header: ( + <> + + Files + + {cwd ? cwd : "this session's drive"} + + + ), + footer: (activeItem) => filesHints(activeItem, cwd || undefined), + emptyText: (q) => `No file or folder matches “${q}”`, + } + + return ( +
+ +
+ Submitted: {last || "—"} +
+
+ ) +} + +/** The `@` palette: a live composer, plus the states a reviewer cannot reach by typing. */ +export const FileMentions: Story = { + render: () => { + const Board = () => { + const [cwd, setCwd] = useState("") + const rows = cwd ? FILE_ROWS.slice(2) : FILE_ROWS + const panel = ( + title: string, + props: Partial & {sections: PalettePanelProps["sections"]}, + ) => ( +
+
+ {title} +
+ `${title}-${i}`} + onHover={() => {}} + onSelect={() => {}} + onDrillIn={() => {}} + floatingRef={() => {}} + floatingStyles={{position: "relative"}} + {...props} + /> +
+ ) + const items = rows.map((row) => fileItem(row, () => setCwd(row.path))) + return ( +
+ +
+ {panel("Root", { + sections: [ + { + key: "recent", + title: "Recently touched", + items: items.slice(2, 3), + }, + {key: "root", title: "Root", items}, + ], + header: ( + <> + + Files + + this session's drive + + + ), + footer: filesHints(items[0]), + })} + {panel("Search", { + query: "guide", + sections: [ + { + key: "hits", + title: "", + items: [ + fileItem({ + path: "agenta/docs/guide", + folder: true, + tail: "9 files", + }), + fileItem({ + path: "agenta/docs/guide/quickstart.mdx", + tail: "4.8 KB", + }), + ], + }, + ], + header: ( + <> + + Files + + across the drive + + + ), + footer: filesHints(undefined), + })} + {panel("Listing a folder", { + sections: [], + loading: true, + header: ( + <> + + audits/2026-08 + + + listing… + + + ), + footer: filesHints(undefined, "audits/2026-08"), + })} + {panel("No matches", { + query: "sitemap", + sections: [], + emptyText: ( + <> + No file or folder matches “sitemap” +
+ Enter sends the message as written. +
+ + ), + footer: filesHints(undefined), + })} +
+
+ ) + } + return + }, +}