From 5b9da8a8f7073b680935282c0f533e6bc6961fbd Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 8 Apr 2026 15:31:27 -0700 Subject: [PATCH] Reapply "feat(app): build React session composer parity on Lexical (#1367)" This reverts commit 2e29a2115113deaefe4eb8bdce97e1555f80fed6. --- apps/app/package.json | 2 + apps/app/src/app/pages/session.tsx | 24 +- apps/app/src/react/island.tsx | 2 +- .../react/session/composer/composer.react.tsx | 604 ++++++++++++++++++ .../react/session/composer/editor.react.tsx | 536 ++++++++++++++++ .../react/session/composer/notice.react.tsx | 48 ++ apps/app/src/react/session/markdown.react.tsx | 38 +- .../src/react/session/message-list.react.tsx | 174 ++++- .../react/session/session-surface.react.tsx | 402 ++++++++++-- .../app/src/react/session/tool-call.react.tsx | 37 +- pnpm-lock.yaml | 324 ++++++++++ 11 files changed, 2115 insertions(+), 76 deletions(-) create mode 100644 apps/app/src/react/session/composer/composer.react.tsx create mode 100644 apps/app/src/react/session/composer/editor.react.tsx create mode 100644 apps/app/src/react/session/composer/notice.react.tsx diff --git a/apps/app/package.json b/apps/app/package.json index 2c69c308..a093b7ad 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -41,6 +41,7 @@ "@codemirror/language": "^6.11.0", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.0", + "@lexical/react": "^0.35.0", "@opencode-ai/sdk": "^1.1.31", "@openwork/ui": "workspace:*", "@radix-ui/colors": "^3.0.0", @@ -60,6 +61,7 @@ "fuzzysort": "^3.1.0", "jsonc-parser": "^3.2.1", "lucide-solid": "^0.562.0", + "lexical": "^0.35.0", "marked": "^17.0.1", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index 2fd8251e..c2c96991 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -3266,7 +3266,7 @@ export default function SessionView(props: SessionViewProps) { }} >
{ chatContentEl = el; }} @@ -3379,6 +3379,28 @@ export default function SessionView(props: SessionViewProps) { opencodeBaseUrl: reactSessionOpencodeBaseUrl(), openworkToken: reactSessionToken(), developerMode: props.developerMode, + modelLabel: modelControls.selectedSessionModelLabel() || t("session.model_fallback"), + onModelClick: () => modelControls.openSessionModelPicker(), + onSendDraft: handleSendPrompt, + onDraftChange: handleDraftChange, + attachmentsEnabled: attachmentsEnabled(), + attachmentsDisabledReason: attachmentsDisabledReason(), + modelVariantLabel: modelControls.sessionModelVariantLabel(), + modelVariant: modelControls.sessionModelVariant(), + modelBehaviorOptions: modelControls.sessionModelBehaviorOptions(), + onModelVariantChange: modelControls.setSessionModelVariant, + agentLabel: agentLabel(), + selectedAgent: sessionActions.selectedSessionAgent(), + listAgents: sessionActions.listAgents, + onSelectAgent: (agent) => { + void applySessionAgent(agent); + }, + listCommands: sessionActions.listCommands, + recentFiles: props.workingFiles, + searchFiles: sessionActions.searchWorkspaceFiles, + isRemoteWorkspace: props.selectedWorkspaceDisplay.workspaceType === "remote", + isSandboxWorkspace: isSandboxWorkspace(), + onUploadInboxFiles: uploadInboxFiles, }} /> } diff --git a/apps/app/src/react/island.tsx b/apps/app/src/react/island.tsx index bbb4d2ae..1f09c9f8 100644 --- a/apps/app/src/react/island.tsx +++ b/apps/app/src/react/island.tsx @@ -43,5 +43,5 @@ export function ReactIsland(props: ReactIslandProps) { root = null; }); - return
; + return
; } diff --git a/apps/app/src/react/session/composer/composer.react.tsx b/apps/app/src/react/session/composer/composer.react.tsx new file mode 100644 index 00000000..000a6093 --- /dev/null +++ b/apps/app/src/react/session/composer/composer.react.tsx @@ -0,0 +1,604 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import type { Agent } from "@opencode-ai/sdk/v2/client"; +import fuzzysort from "fuzzysort"; +import type { ComposerAttachment } from "../../../app/types"; +import { LexicalPromptEditor } from "./editor.react"; +import type { SlashCommandOption } from "../../../app/types"; +import { ReactComposerNotice, type ReactComposerNotice as ReactComposerNoticeData } from "./notice.react"; + +type MentionItem = { + id: string; + kind: "agent" | "file"; + value: string; + label: string; +}; + +type PastedTextChip = { + id: string; + label: string; + text: string; + lines: number; +}; + +type ComposerProps = { + draft: string; + mentions: Record; + onDraftChange: (value: string) => void; + onSend: () => void | Promise; + onStop: () => void | Promise; + busy: boolean; + disabled: boolean; + statusLabel: string; + modelLabel: string; + onModelClick: () => void; + attachments: ComposerAttachment[]; + onAttachFiles: (files: File[]) => void; + onRemoveAttachment: (id: string) => void; + attachmentsEnabled: boolean; + attachmentsDisabledReason: string | null; + modelVariantLabel: string; + modelVariant: string | null; + modelBehaviorOptions?: { value: string | null; label: string }[]; + onModelVariantChange: (value: string | null) => void; + agentLabel: string; + selectedAgent: string | null; + listAgents: () => Promise; + onSelectAgent: (agent: string | null) => void; + listCommands: () => Promise; + recentFiles: string[]; + searchFiles: (query: string) => Promise; + onInsertMention: (kind: "agent" | "file", value: string) => void; + notice: ReactComposerNoticeData | null; + onNotice: (notice: ReactComposerNoticeData) => void; + onPasteText: (text: string) => void; + onUnsupportedFileLinks: (links: string[]) => void; + pastedText: PastedTextChip[]; + onRevealPastedText: (id: string) => void; + onRemovePastedText: (id: string) => void; + isRemoteWorkspace: boolean; + isSandboxWorkspace: boolean; + onUploadInboxFiles?: ((files: File[]) => void | Promise) | null; +}; + +function parseClipboardLinks(text: string) { + return Array.from(text.matchAll(/https?:\/\/\S+/g)).map((match) => match[0]).filter(Boolean); +} + +function countLines(text: string) { + return text ? text.split(/\r?\n/).length : 0; +} + +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function isImageAttachment(attachment: ComposerAttachment) { + return attachment.kind === "image" || attachment.mimeType.startsWith("image/"); +} + +export function ReactSessionComposer(props: ComposerProps) { + let fileInput: HTMLInputElement | undefined; + const [agents, setAgents] = useState([]); + const [agentMenuOpen, setAgentMenuOpen] = useState(false); + const [variantMenuOpen, setVariantMenuOpen] = useState(false); + const [commands, setCommands] = useState([]); + const [slashOpen, setSlashOpen] = useState(false); + const [mentionItems, setMentionItems] = useState([]); + const [mentionOpen, setMentionOpen] = useState(false); + const [menuIndex, setMenuIndex] = useState(0); + const menuItemRefs = useRef>([]); + const [agentMenuIndex, setAgentMenuIndex] = useState(0); + const agentItemRefs = useRef>([]); + const [dropzoneActive, setDropzoneActive] = useState(false); + + const slashMatch = props.draft.match(/^\/(\S*)$/); + const slashQuery = slashMatch?.[1] ?? ""; + const mentionMatch = props.draft.match(/@([^\s@]*)$/); + const mentionQuery = mentionMatch?.[1] ?? ""; + + useEffect(() => { + setSlashOpen(Boolean(slashMatch)); + setMenuIndex(0); + }, [slashMatch]); + + useEffect(() => { + setMentionOpen(Boolean(mentionMatch)); + setMenuIndex(0); + }, [mentionMatch]); + + useEffect(() => { + if (!agentMenuOpen) return; + void props.listAgents().then(setAgents).catch(() => setAgents([])); + }, [agentMenuOpen, props]); + + useEffect(() => { + setAgentMenuIndex(0); + }, [agentMenuOpen]); + + useEffect(() => { + const target = agentItemRefs.current[agentMenuIndex]; + target?.scrollIntoView({ block: "nearest" }); + }, [agentMenuIndex, agentMenuOpen]); + + useEffect(() => { + if (!slashOpen) return; + void props.listCommands().then(setCommands).catch(() => setCommands([])); + }, [slashOpen, props]); + + useEffect(() => { + if (!mentionOpen) return; + let cancelled = false; + void Promise.all([props.listAgents(), props.searchFiles(mentionQuery)]).then(([agentList, files]) => { + if (cancelled) return; + const recent = props.recentFiles.slice(0, 8); + const next: MentionItem[] = [ + ...agentList.map((agent) => ({ id: `agent:${agent.name}`, kind: "agent" as const, value: agent.name, label: agent.name })), + ...recent.map((file) => ({ id: `file:${file}`, kind: "file" as const, value: file, label: file })), + ...files.filter((file) => !recent.includes(file)).map((file) => ({ id: `file:${file}`, kind: "file" as const, value: file, label: file })), + ]; + setMentionItems(next); + }).catch(() => { + if (!cancelled) setMentionItems([]); + }); + return () => { + cancelled = true; + }; + }, [mentionOpen, mentionQuery, props]); + + const slashFiltered = !slashOpen + ? [] + : slashQuery + ? fuzzysort.go(slashQuery, commands, { keys: ["name", "description"] }).map((entry) => entry.obj).slice(0, 8) + : commands.slice(0, 8); + const mentionFiltered = !mentionOpen + ? [] + : mentionQuery + ? fuzzysort.go(mentionQuery, mentionItems, { keys: ["label"] }).map((entry) => entry.obj).slice(0, 8) + : mentionItems.slice(0, 8); + + const activeMenu = slashOpen ? "slash" : mentionOpen ? "mention" : null; + const activeItems = activeMenu === "slash" ? slashFiltered : activeMenu === "mention" ? mentionFiltered : []; + + useEffect(() => { + if (!activeItems.length) { + setMenuIndex(0); + return; + } + setMenuIndex((current) => Math.max(0, Math.min(current, activeItems.length - 1))); + }, [activeItems.length]); + + useEffect(() => { + const target = menuItemRefs.current[menuIndex]; + target?.scrollIntoView({ block: "nearest" }); + }, [menuIndex, activeItems.length]); + + const acceptActiveItem = () => { + if (!activeItems.length) return false; + if (activeMenu === "slash") { + const command = slashFiltered[menuIndex]; + if (!command) return false; + props.onDraftChange(`/${command.name} `); + setSlashOpen(false); + return true; + } + if (activeMenu === "mention") { + const item = mentionFiltered[menuIndex]; + if (!item) return false; + props.onInsertMention(item.kind, item.value); + setMentionOpen(false); + return true; + } + return false; + }; + + const handleKeyDownCapture: React.KeyboardEventHandler = (event) => { + if (agentMenuOpen) { + const total = agents.length + 1; + if (event.key === "ArrowDown") { + event.preventDefault(); + setAgentMenuIndex((current) => (current + 1) % total); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setAgentMenuIndex((current) => (current - 1 + total) % total); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + const selected = agentMenuIndex === 0 ? null : agents[agentMenuIndex - 1]?.name ?? null; + props.onSelectAgent(selected); + setAgentMenuOpen(false); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setAgentMenuOpen(false); + setVariantMenuOpen(false); + return; + } + } + + if (!activeMenu || !activeItems.length) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setMenuIndex((current) => (current + 1) % activeItems.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setMenuIndex((current) => (current - 1 + activeItems.length) % activeItems.length); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + void acceptActiveItem(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setSlashOpen(false); + setMentionOpen(false); + } + }; + + return ( +
+
+
+
+ + {props.modelBehaviorOptions?.length ? ( +
+ + {variantMenuOpen ? ( +
+ {props.modelBehaviorOptions.map((option) => ( + + ))} +
+ ) : null} +
+ ) : null} +
+ + {agentMenuOpen ? ( +
+ + {agents.map((agent, index) => ( + + ))} +
+ ) : null} +
+
+ { + fileInput = element ?? undefined; + }} + type="file" + multiple + className="hidden" + onChange={(event) => { + const files = Array.from(event.currentTarget.files ?? []); + if (files.length) props.onAttachFiles(files); + event.currentTarget.value = ""; + }} + /> + +
+ {props.attachments.length > 0 ? ( +
+ {props.attachments.map((attachment) => ( +
+ {isImageAttachment(attachment) && attachment.previewUrl ? ( +
+ {attachment.name} +
+ ) : ( +
+ {isImageAttachment(attachment) ? "๐Ÿ–ผ๏ธ" : "๐Ÿ“„"} +
+ )} +
+
{attachment.name}
+
+ {attachment.mimeType || "application/octet-stream"} + {formatBytes(attachment.size)} +
+
+ +
+ ))} +
+ ) : null} + {props.pastedText.length > 0 ? ( +
+ {props.pastedText.map((item) => ( +
+
+
Pasted text ยท {item.label}
+
{item.lines} lines
+
+ + + +
+ ))} +
+ ) : null} +
+ + {dropzoneActive ? ( +
+
+
Drop files to attach
+
Images, text files, and PDFs are supported.
+
+
+ ) : null} + ({ label: item.label, lines: item.lines }))} + disabled={props.disabled} + placeholder="Describe your task..." + onChange={props.onDraftChange} + onSubmit={props.onSend} + onPaste={(event) => { + const files = Array.from(event.clipboardData?.files ?? []); + const text = event.clipboardData?.getData("text/plain") ?? ""; + if (files.length) { + event.preventDefault(); + const supported = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("text/") || file.type === "application/pdf"); + const unsupported = files.filter((file) => !supported.includes(file)); + if (supported.length) { + if (!props.attachmentsEnabled) { + props.onNotice({ + title: props.attachmentsDisabledReason ?? "Attachments are unavailable.", + tone: "warning", + }); + } else { + props.onAttachFiles(supported); + props.onNotice({ + title: supported.length === 1 ? `Attached ${supported[0]?.name ?? "file"}` : `Attached ${supported.length} files`, + tone: "success", + }); + } + } + if (unsupported.length) { + props.onUnsupportedFileLinks(parseClipboardLinks(text)); + props.onNotice({ title: "Inserted links for unsupported files", tone: "info" }); + } + return; + } + + if (!text.trim()) return; + if ((props.isRemoteWorkspace || props.isSandboxWorkspace) && /file:\/\/|(^|\s)\/(Users|home|var|etc|opt|tmp|private|Volumes|Applications)\//.test(text)) { + const attachedFiles = props.attachments.map((attachment) => attachment.file); + props.onNotice({ + title: "Pasted local paths may not exist on the connected worker.", + tone: "warning", + actionLabel: + props.onUploadInboxFiles && attachedFiles.length > 0 + ? `Upload ${attachedFiles.length === 1 ? "attached file" : `${attachedFiles.length} attached files`}` + : undefined, + onAction: + props.onUploadInboxFiles && attachedFiles.length > 0 + ? () => void props.onUploadInboxFiles?.(attachedFiles) + : undefined, + }); + } + + if (countLines(text) > 10) { + event.preventDefault(); + props.onPasteText(text); + props.onNotice({ title: "Inserted pasted text as a collapsed chip", tone: "info" }); + } + }} + onDragOver={(event) => { + if (event.dataTransfer?.files?.length) { + event.preventDefault(); + if (!dropzoneActive) setDropzoneActive(true); + } + }} + onDragLeave={(event) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setDropzoneActive(false); + }} + onDrop={(event) => { + const files = Array.from(event.dataTransfer?.files ?? []); + setDropzoneActive(false); + if (!files.length) return; + event.preventDefault(); + const supported = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("text/") || file.type === "application/pdf"); + const unsupported = files.filter((file) => !supported.includes(file)); + if (supported.length) { + if (!props.attachmentsEnabled) { + props.onNotice({ + title: props.attachmentsDisabledReason ?? "Attachments are unavailable.", + tone: "warning", + }); + } else { + props.onAttachFiles(supported); + props.onNotice({ + title: supported.length === 1 ? `Attached ${supported[0]?.name ?? "file"}` : `Attached ${supported.length} files`, + tone: "success", + }); + } + } + if (unsupported.length) { + props.onNotice({ + title: unsupported.length === 1 ? `${unsupported[0]?.name ?? "File"} could not be attached` : `${unsupported.length} files could not be attached`, + description: "Drop supports images, text files, and PDFs for now.", + tone: "info", + }); + } + }} + /> +
+ {slashOpen && slashFiltered.length > 0 ? ( +
+
+ {slashFiltered.map((command, index) => ( + + ))} +
+
+ ) : null} + {mentionOpen && mentionFiltered.length > 0 ? ( +
+
+ {mentionFiltered.map((item, index) => ( + + ))} +
+
+ ) : null} +
+
{props.statusLabel}
+ {props.busy ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/app/src/react/session/composer/editor.react.tsx b/apps/app/src/react/session/composer/editor.react.tsx new file mode 100644 index 00000000..9567ac62 --- /dev/null +++ b/apps/app/src/react/session/composer/editor.react.tsx @@ -0,0 +1,536 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer.js"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin.js"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable.js"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary.js"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin.js"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin.js"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext.js"; +import { + $applyNodeReplacement, + $createRangeSelection, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $setSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_HIGH, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_ENTER_COMMAND, + type SerializedTextNode, + type Spread, + TextNode, + type EditorConfig, + type NodeKey, +} from "lexical"; +import type { InitialConfigType } from "@lexical/react/LexicalComposer.js"; + +type EditorProps = { + value: string; + mentions: Record; + pastedText?: Array<{ label: string; lines: number }>; + disabled: boolean; + placeholder: string; + onChange: (value: string) => void; + onSubmit: () => void | Promise; + onPaste?: React.ClipboardEventHandler; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; +}; + +type SerializedComposerMentionNode = Spread< + { + mentionValue: string; + mentionKind: "agent" | "file"; + type: "composer-mention"; + version: 1; + }, + SerializedTextNode +>; + +type SerializedComposerSlashCommandNode = Spread< + { + commandName: string; + type: "composer-slash-command"; + version: 1; + }, + SerializedTextNode +>; + +class ComposerMentionNode extends TextNode { + __value: string; + __kind: "agent" | "file"; + + static override getType() { + return "composer-mention"; + } + + static override clone(node: ComposerMentionNode) { + return new ComposerMentionNode(node.__value, node.__kind, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerMentionNode) { + return $createComposerMentionNode(serializedNode.mentionValue, serializedNode.mentionKind); + } + + constructor(value = "", kind: "agent" | "file" = "file", key?: NodeKey) { + super(`@${value}`, key); + this.__value = value; + this.__kind = kind; + } + + override exportJSON(): SerializedComposerMentionNode { + return { + ...super.exportJSON(), + mentionValue: this.__value, + mentionKind: this.__kind, + type: "composer-mention", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + const isFile = this.__kind === "file"; + dom.className = this.__kind === "file" + ? "inline-flex items-center rounded-full border border-gray-6 bg-gray-3 px-2.5 py-1 text-xs font-medium text-gray-11" + : "inline-flex items-center rounded-full border border-sky-6/35 bg-sky-3/20 px-2.5 py-1 text-xs font-medium text-sky-11"; + dom.textContent = `${isFile ? "๐Ÿ“„ " : "๐Ÿค– "}@${isFile ? this.__value.split(/[\\/]/).pop() || this.__value : this.__value}`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `@${this.__value}`; + return dom; + } + + override updateDOM(prevNode: ComposerMentionNode, dom: HTMLElement) { + if (prevNode.__value !== this.__value || prevNode.__kind !== this.__kind) { + const isFile = this.__kind === "file"; + dom.className = this.__kind === "file" + ? "inline-flex items-center rounded-full border border-gray-6 bg-gray-3 px-2.5 py-1 text-xs font-medium text-gray-11" + : "inline-flex items-center rounded-full border border-sky-6/35 bg-sky-3/20 px-2.5 py-1 text-xs font-medium text-sky-11"; + dom.textContent = `${isFile ? "๐Ÿ“„ " : "๐Ÿค– "}@${isFile ? this.__value.split(/[\\/]/).pop() || this.__value : this.__value}`; + dom.title = `@${this.__value}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerMentionNode(value: string, kind: "agent" | "file") { + return $applyNodeReplacement(new ComposerMentionNode(value, kind)); +} + +class ComposerSlashCommandNode extends TextNode { + __commandName: string; + + static override getType() { + return "composer-slash-command"; + } + + static override clone(node: ComposerSlashCommandNode) { + return new ComposerSlashCommandNode(node.__commandName, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerSlashCommandNode) { + return $createComposerSlashCommandNode(serializedNode.commandName); + } + + constructor(commandName = "", key?: NodeKey) { + super(`/${commandName}`, key); + this.__commandName = commandName; + } + + override exportJSON(): SerializedComposerSlashCommandNode { + return { + ...super.exportJSON(), + commandName: this.__commandName, + type: "composer-slash-command", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + dom.className = "inline-flex items-center rounded-full border border-violet-6/35 bg-violet-3/20 px-2.5 py-1 text-xs font-medium text-violet-11"; + dom.textContent = `/${this.__commandName}`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `/${this.__commandName}`; + return dom; + } + + override updateDOM(prevNode: ComposerSlashCommandNode, dom: HTMLElement) { + if (prevNode.__commandName !== this.__commandName) { + dom.textContent = `/${this.__commandName}`; + dom.title = `/${this.__commandName}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerSlashCommandNode(commandName: string) { + return $applyNodeReplacement(new ComposerSlashCommandNode(commandName)); +} + +type SerializedComposerPastedTextNode = Spread< + { + pastedLabel: string; + pastedLines: number; + type: "composer-pasted-text"; + version: 1; + }, + SerializedTextNode +>; + +class ComposerPastedTextNode extends TextNode { + __pastedLabel: string; + __pastedLines: number; + + static override getType() { + return "composer-pasted-text"; + } + + static override clone(node: ComposerPastedTextNode) { + return new ComposerPastedTextNode(node.__pastedLabel, node.__pastedLines, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerPastedTextNode) { + return $createComposerPastedTextNode(serializedNode.pastedLabel, serializedNode.pastedLines); + } + + constructor(label = "", lines = 0, key?: NodeKey) { + super(`[pasted text ${label}]`, key); + this.__pastedLabel = label; + this.__pastedLines = lines; + } + + override exportJSON(): SerializedComposerPastedTextNode { + return { + ...super.exportJSON(), + pastedLabel: this.__pastedLabel, + pastedLines: this.__pastedLines, + type: "composer-pasted-text", + version: 1, + }; + } + + override createDOM(_config: EditorConfig) { + const dom = document.createElement("span"); + dom.className = "inline-flex items-center gap-1 rounded-full border border-amber-6/35 bg-amber-3/15 px-2.5 py-1 text-xs font-medium text-amber-11"; + dom.textContent = `๐Ÿ“‹ ${this.__pastedLines} lines`; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); + dom.title = `Pasted text ยท ${this.__pastedLabel}`; + return dom; + } + + override updateDOM(prevNode: ComposerPastedTextNode, dom: HTMLElement) { + if (prevNode.__pastedLabel !== this.__pastedLabel || prevNode.__pastedLines !== this.__pastedLines) { + dom.textContent = `๐Ÿ“‹ ${this.__pastedLines} lines`; + dom.title = `Pasted text ยท ${this.__pastedLabel}`; + } + return false; + } + + override canInsertTextBefore(): false { + return false; + } + + override canInsertTextAfter(): false { + return false; + } + + override isTextEntity(): true { + return true; + } + + override isToken(): true { + return true; + } +} + +function $createComposerPastedTextNode(label: string, lines: number) { + return $applyNodeReplacement(new ComposerPastedTextNode(label, lines)); +} + +type ComposerInlineTokenNode = ComposerMentionNode | ComposerSlashCommandNode | ComposerPastedTextNode; + +function setSelectionAfterNode(node: ComposerInlineTokenNode) { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return; + const selection = $createRangeSelection(); + const offset = node.getIndexWithinParent() + 1; + selection.anchor.set(parent.getKey(), offset, "element"); + selection.focus.set(parent.getKey(), offset, "element"); + $setSelection(selection); +} + +function setSelectionBeforeNode(node: ComposerInlineTokenNode) { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return; + const selection = $createRangeSelection(); + const offset = node.getIndexWithinParent(); + selection.anchor.set(parent.getKey(), offset, "element"); + selection.focus.set(parent.getKey(), offset, "element"); + $setSelection(selection); +} + +function setPrompt(value: string, mentions: Record, pastedText?: Array<{ label: string; lines: number }>) { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const slashMatch = value.match(/^\/(\S+)\s(.*)$/s); + if (slashMatch?.[1]) { + paragraph.append($createComposerSlashCommandNode(slashMatch[1])); + paragraph.append($createTextNode(" ")); + value = slashMatch[2] ?? ""; + } + const segments = value.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/); + for (const segment of segments) { + if (!segment) continue; + const pasteMatch = segment.match(/^\[pasted text (.+)\]$/); + if (pasteMatch?.[1]) { + const target = pastedText?.find((item) => item.label === pasteMatch[1]); + if (target) { + paragraph.append($createComposerPastedTextNode(target.label, target.lines)); + continue; + } + } + if (segment.startsWith("@")) { + const token = segment.slice(1); + const kind = mentions[token]; + if (kind) { + paragraph.append($createComposerMentionNode(token, kind)); + continue; + } + } + paragraph.append($createTextNode(segment)); + } +} + +function SyncPlugin(props: { value: string; mentions: Record; pastedText?: Array<{ label: string; lines: number }>; disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + const valueRef = useRef(props.value); + + useEffect(() => { + editor.setEditable(!props.disabled); + }, [editor, props.disabled]); + + useEffect(() => { + if (valueRef.current === props.value) return; + valueRef.current = props.value; + editor.update(() => { + const root = $getRoot(); + if (root.getTextContent() === props.value) return; + setPrompt(props.value, props.mentions, props.pastedText); + root.selectEnd(); + }); + }, [editor, props.mentions, props.value]); + + return null; +} + +function SubmitPlugin(props: { onSubmit: () => void | Promise; disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + if (props.disabled) return false; + if (!event?.metaKey && !event?.ctrlKey) return false; + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return false; + event.preventDefault(); + void props.onSubmit(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, props.disabled, props.onSubmit]); + + return null; +} + +function MentionChipNavigationPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const unregisterBackspace = editor.registerCommand( + KEY_BACKSPACE_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { + const previous = anchorNode.getPreviousSibling(); + if (previous instanceof ComposerMentionNode || previous instanceof ComposerSlashCommandNode || previous instanceof ComposerPastedTextNode) { + previous.remove(); + return true; + } + } + + if ($isElementNode(anchorNode)) { + const previous = anchorNode.getChildAtIndex(selection.anchor.offset - 1); + if (previous instanceof ComposerMentionNode || previous instanceof ComposerPastedTextNode) { + previous.remove(); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + const unregisterLeft = editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { + const previous = anchorNode.getPreviousSibling(); + if (previous instanceof ComposerMentionNode || previous instanceof ComposerSlashCommandNode || previous instanceof ComposerPastedTextNode) { + setSelectionBeforeNode(previous); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + const unregisterRight = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + const anchorNode = selection.anchor.getNode(); + + if (anchorNode instanceof ComposerMentionNode || anchorNode instanceof ComposerSlashCommandNode || anchorNode instanceof ComposerPastedTextNode) { + setSelectionAfterNode(anchorNode); + return true; + } + + if ($isElementNode(anchorNode)) { + const current = anchorNode.getChildAtIndex(selection.anchor.offset); + if (current instanceof ComposerMentionNode || current instanceof ComposerSlashCommandNode || current instanceof ComposerPastedTextNode) { + setSelectionAfterNode(current); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ); + + return () => { + unregisterBackspace(); + unregisterLeft(); + unregisterRight(); + }; + }, [editor]); + + return null; +} + +export function LexicalPromptEditor(props: EditorProps) { + const initialConfig = useMemo( + () => ({ + namespace: "openwork-react-session-composer", + onError(error: Error) { + throw error; + }, + editable: !props.disabled, + nodes: [ComposerMentionNode, ComposerSlashCommandNode, ComposerPastedTextNode], + editorState: () => { + setPrompt(props.value, props.mentions, props.pastedText); + }, + }), + [], + ); + + const handleChange = useCallback( + (state: Parameters["onChange"]>>[0]) => { + state.read(() => { + props.onChange($getRoot().getTextContent()); + }); + }, + [props], + ); + + return ( + +
+ } + onPaste={props.onPaste} + onDrop={props.onDrop} + onDragOver={props.onDragOver} + onDragLeave={props.onDragLeave} + /> + } + placeholder={ +
+ {props.placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + +
+
+ ); +} diff --git a/apps/app/src/react/session/composer/notice.react.tsx b/apps/app/src/react/session/composer/notice.react.tsx new file mode 100644 index 00000000..77f93452 --- /dev/null +++ b/apps/app/src/react/session/composer/notice.react.tsx @@ -0,0 +1,48 @@ +/** @jsxImportSource react */ + +export type ReactComposerNotice = { + title: string; + description?: string | null; + tone?: "info" | "success" | "warning" | "error"; + actionLabel?: string; + onAction?: () => void; +}; + +export function ReactComposerNotice(props: { notice: ReactComposerNotice | null }) { + const tone = props.notice?.tone ?? "info"; + if (!props.notice) return null; + + const toneClass = + tone === "success" + ? "border-emerald-6/40 bg-emerald-4/80 text-emerald-11" + : tone === "warning" + ? "border-amber-6/40 bg-amber-4/80 text-amber-11" + : tone === "error" + ? "border-red-6/40 bg-red-4/80 text-red-11" + : "border-sky-6/40 bg-sky-4/80 text-sky-11"; + + return ( +
+
+
+ {tone === "success" ? "โœ“" : tone === "warning" ? "!" : tone === "error" ? "ร—" : "i"} +
+
+
{props.notice.title}
+ {props.notice.description?.trim() ? ( +

{props.notice.description}

+ ) : null} + {props.notice.actionLabel && props.notice.onAction ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/src/react/session/markdown.react.tsx b/apps/app/src/react/session/markdown.react.tsx index 10c4b5f8..2a6d99ca 100644 --- a/apps/app/src/react/session/markdown.react.tsx +++ b/apps/app/src/react/session/markdown.react.tsx @@ -1,9 +1,36 @@ /** @jsxImportSource react */ +import { useState } from "react"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Streamdown } from "streamdown"; +function MarkdownCodeBlock(props: { className?: string; children: React.ReactNode }) { + const text = Array.isArray(props.children) ? props.children.join("") : String(props.children ?? ""); + const [copied, setCopied] = useState(false); + + return ( +
+
+ +
+
+        {props.children}
+      
+
+ ); +} + const markdownComponents: Components = { a({ href, children }) { return ( @@ -27,7 +54,7 @@ const markdownComponents: Components = { code({ className, children }) { const isBlock = Boolean(className?.includes("language-")); if (isBlock) { - return {children}; + return {children}; } return ( @@ -47,14 +74,17 @@ const markdownComponents: Components = { td({ children }) { return {children}; }, + hr() { + return
; + }, }; const markdownClassName = `markdown-content max-w-none text-gray-12 [&_strong]:font-semibold [&_em]:italic - [&_h1]:my-4 [&_h1]:text-2xl [&_h1]:font-bold - [&_h2]:my-3 [&_h2]:text-xl [&_h2]:font-bold - [&_h3]:my-2 [&_h3]:text-lg [&_h3]:font-bold + [&_h1]:my-5 [&_h1]:text-xl [&_h1]:font-semibold + [&_h2]:my-4 [&_h2]:text-lg [&_h2]:font-semibold + [&_h3]:my-3 [&_h3]:text-base [&_h3]:font-semibold [&_p]:my-3 [&_p]:leading-relaxed [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6 diff --git a/apps/app/src/react/session/message-list.react.tsx b/apps/app/src/react/session/message-list.react.tsx index 11ee9af6..f15c7bb2 100644 --- a/apps/app/src/react/session/message-list.react.tsx +++ b/apps/app/src/react/session/message-list.react.tsx @@ -1,13 +1,58 @@ /** @jsxImportSource react */ +import { useState } from "react"; import { isToolUIPart, type DynamicToolUIPart, type UIMessage } from "ai"; import { MarkdownBlock } from "./markdown.react"; import { ToolCallView } from "./tool-call.react"; +function CopyButton(props: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + function isImageAttachment(mime: string) { return mime.startsWith("image/"); } +function messageToText(message: UIMessage) { + return message.parts + .flatMap((part) => { + if (part.type === "text") return [part.text]; + if (part.type === "reasoning") return [part.text]; + if (part.type === "file") return [part.filename ?? part.url]; + if (isToolUIPart(part)) { + const toolName = part.type === "dynamic-tool" ? part.toolName : part.type.replace(/^tool-/, ""); + if (part.state === "output-error") return [`[tool:${toolName}] ${part.errorText}`]; + if (part.state === "output-available") return [`[tool:${toolName}] ${JSON.stringify(part.output)}`]; + return [`[tool:${toolName}] ${JSON.stringify(part.input)}`]; + } + return []; + }) + .join("\n\n") + .trim(); +} + +async function copyMessage(message: UIMessage) { + await navigator.clipboard.writeText(messageToText(message)); +} + function latestAssistantMessageId(messages: UIMessage[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -16,29 +61,124 @@ function latestAssistantMessageId(messages: UIMessage[]) { return null; } +function humanMediaType(raw: string) { + if (!raw || raw === "application/octet-stream") return null; + const short = raw.replace(/^application\//, "").replace(/^text\//, ""); + return short.toUpperCase(); +} + +function isDesktopRuntime() { + try { + return Boolean((window as unknown as Record).__TAURI_INTERNALS__); + } catch { + return false; + } +} + +async function openFileWithOS(path: string) { + try { + const { openPath } = await import("@tauri-apps/plugin-opener"); + await openPath(path); + } catch { + // silently fail on web + } +} + +async function revealFileInFinder(path: string) { + try { + const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + await revealItemInDir(path); + } catch { + // silently fail on web + } +} + function FileCard(props: { part: { filename?: string; url: string; mediaType: string }; tone: "assistant" | "user" }) { - const title = props.part.filename || props.part.url || "File"; - const detail = props.part.url || ""; + const [menuOpen, setMenuOpen] = useState(false); + const isDataUrl = props.part.url?.startsWith("data:"); + const title = props.part.filename || (isDataUrl ? "Attached file" : props.part.url) || "File"; + const ext = props.part.filename?.split(".").pop()?.toLowerCase(); + const badge = humanMediaType(props.part.mediaType) ?? (ext ? ext.toUpperCase() : null); + const isImage = isImageAttachment(props.part.mediaType ?? ""); + const isDesktop = isDesktopRuntime(); + const hasPath = !isDataUrl && props.part.url && !props.part.url.startsWith("http"); + return (
- {props.part.url && isImageAttachment(props.part.mediaType ?? "") ? ( -
- {props.part.filename + {isImage && props.part.url ? ( +
+ {title}
) : ( -
- ๐Ÿ“„ +
+
)}
-
{title}
- {detail ?
{detail}
: null} +
{title}
+ {badge ? ( +
{badge}
+ ) : null}
- {props.part.mediaType ?
{props.part.mediaType}
: null} + + {isDesktop && hasPath ? ( +
+ + {menuOpen ? ( + <> +
setMenuOpen(false)} /> +
+ + + +
+ + ) : null} +
+ ) : null}
); } @@ -58,6 +198,9 @@ function AssistantBlock(props: { message: UIMessage; developerMode: boolean; isS return (
+
+ +
{props.message.parts.map((part, index) => { if (part.type === "text") { @@ -79,7 +222,7 @@ function AssistantBlock(props: { message: UIMessage; developerMode: boolean; isS } if (part.type === "step-start") { - return
Step started
; + return null; } if (isToolUIPart(part)) { @@ -111,7 +254,10 @@ function UserBlock(props: { message: UIMessage }) { return (
-
+
+
+ +
{attachments.length > 0 ? (
{attachments.map((part, index) => ( diff --git a/apps/app/src/react/session/session-surface.react.tsx b/apps/app/src/react/session/session-surface.react.tsx index ba75df28..3a83b196 100644 --- a/apps/app/src/react/session/session-surface.react.tsx +++ b/apps/app/src/react/session/session-surface.react.tsx @@ -1,15 +1,25 @@ /** @jsxImportSource react */ -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { UIMessage } from "ai"; import { useQuery } from "@tanstack/react-query"; import { createClient } from "../../app/lib/opencode"; import { abortSessionSafe } from "../../app/lib/opencode-session"; import type { OpenworkServerClient, OpenworkSessionSnapshot } from "../../app/lib/openwork-server"; +import type { ComposerAttachment, ComposerDraft, ComposerPart } from "../../app/types"; import { SessionDebugPanel } from "./debug-panel.react"; import { SessionTranscript } from "./message-list.react"; import { deriveSessionRenderModel } from "./transition-controller"; import { getReactQueryClient } from "../kernel/query-client"; +import { ReactSessionComposer } from "./composer/composer.react"; +import type { ReactComposerNotice } from "./composer/notice.react"; + +const AUTO_SCROLL_THRESHOLD_PX = 64; +const scrollPositionBySession = new Map(); + +function isNearBottom(el: HTMLElement) { + return el.scrollHeight - el.clientHeight - el.scrollTop <= AUTO_SCROLL_THRESHOLD_PX; +} import { seedSessionState, statusKey as reactStatusKey, @@ -25,8 +35,50 @@ type SessionSurfaceProps = { opencodeBaseUrl: string; openworkToken: string; developerMode: boolean; + modelLabel: string; + onModelClick: () => void; + onSendDraft: (draft: ComposerDraft) => void; + onDraftChange: (draft: ComposerDraft) => void; + attachmentsEnabled: boolean; + attachmentsDisabledReason: string | null; + modelVariantLabel: string; + modelVariant: string | null; + modelBehaviorOptions?: { value: string | null; label: string }[]; + onModelVariantChange: (value: string | null) => void; + agentLabel: string; + selectedAgent: string | null; + listAgents: () => Promise; + onSelectAgent: (agent: string | null) => void; + listCommands: () => Promise; + recentFiles: string[]; + searchFiles: (query: string) => Promise; + isRemoteWorkspace: boolean; + isSandboxWorkspace: boolean; + onUploadInboxFiles?: ((files: File[], options?: { notify?: boolean }) => void | Promise) | null; }; +function transcriptToText(messages: UIMessage[]) { + return messages + .map((message) => { + const header = message.role === "user" ? "You" : message.role === "assistant" ? "OpenWork" : message.role; + const body = message.parts + .flatMap((part) => { + if (part.type === "text") return [part.text]; + if (part.type === "reasoning") return [part.text]; + if (part.type === "dynamic-tool") { + if (part.state === "output-error") return [`[tool:${part.toolName}] ${part.errorText}`]; + if (part.state === "output-available") return [`[tool:${part.toolName}] ${JSON.stringify(part.output)}`]; + return [`[tool:${part.toolName}] ${JSON.stringify(part.input)}`]; + } + return []; + }) + .join("\n\n"); + return `${header}\n${body}`.trim(); + }) + .filter(Boolean) + .join("\n\n---\n\n"); +} + function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolean) { if (busy) return "Running..."; if (snapshot?.status.type === "busy") return "Running..."; @@ -45,8 +97,13 @@ function useSharedQueryState(queryKey: readonly unknown[], fallback: T) { export function SessionSurface(props: SessionSurfaceProps) { const [draft, setDraft] = useState(""); + const [attachments, setAttachments] = useState([]); + const [mentions, setMentions] = useState>({}); + const [pasteParts, setPasteParts] = useState>([]); + const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [sending, setSending] = useState(false); + const [showDelayedLoading, setShowDelayedLoading] = useState(false); const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null); const hydratedKeyRef = useRef(null); const opencodeClient = useMemo( @@ -91,8 +148,19 @@ export function SessionSurface(props: SessionSurfaceProps) { hydratedKeyRef.current = null; setError(null); setSending(false); + setShowDelayedLoading(false); + setAttachments([]); + setMentions({}); + setPasteParts([]); + setNotice(null); }, [props.sessionId]); + useEffect(() => { + if (!notice) return; + const id = window.setTimeout(() => setNotice(null), 2400); + return () => window.clearTimeout(id); + }, [notice]); + useEffect(() => { if (!currentSnapshot) return; seedSessionState(props.workspaceId, currentSnapshot); @@ -110,28 +178,74 @@ export function SessionSurface(props: SessionSurfaceProps) { const liveStatus = statusState ?? snapshot?.status ?? { type: "idle" as const }; const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry"; const renderedMessages = transcriptState ?? []; + const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0; + + useEffect(() => { + if (!pendingSessionLoad) { + setShowDelayedLoading(false); + return; + } + const id = window.setTimeout(() => setShowDelayedLoading(true), 2000); + return () => window.clearTimeout(id); + }, [pendingSessionLoad]); + const model = deriveSessionRenderModel({ intendedSessionId: props.sessionId, renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null, hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0, - isFetching: snapshotQuery.isFetching || chatStreaming, + isFetching: snapshotQuery.isFetching, isError: snapshotQuery.isError || Boolean(error), }); + const buildDraft = (text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => { + const trimmed = text.trim(); + const slashMatch = trimmed.match(/^\/([^\s]+)\s*(.*)$/); + const parts: ComposerPart[] = text.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/).flatMap((segment) => { + if (!segment) return [] as ComposerDraft["parts"]; + const pasteMatch = segment.match(/^\[pasted text (.+)\]$/); + if (pasteMatch) { + const target = pasteParts.find((item) => item.label === pasteMatch[1]); + if (target) { + return [{ type: "paste", id: target.id, label: target.label, text: target.text, lines: target.lines }]; + } + } + if (segment.startsWith("@")) { + const value = segment.slice(1); + const kind = mentions[value]; + if (kind === "agent") return [{ type: "agent", name: value } satisfies ComposerDraft["parts"][number]]; + if (kind === "file") return [{ type: "file", path: value, label: value } satisfies ComposerDraft["parts"][number]]; + } + return [{ type: "text", text: segment } satisfies ComposerDraft["parts"][number]]; + }); + return { + mode: "prompt", + parts, + attachments: nextAttachments, + text, + resolvedText: text, + command: slashMatch ? { name: slashMatch[1] ?? "", arguments: slashMatch[2] ?? "" } : undefined, + }; + }; + + const handleCopyTranscript = async () => { + try { + await navigator.clipboard.writeText(transcriptToText(renderedMessages)); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to copy transcript."); + } + }; + const handleSend = async () => { const text = draft.trim(); if (!text || chatStreaming) return; setError(null); setSending(true); try { - const result = await opencodeClient.session.promptAsync({ - sessionID: props.sessionId, - parts: [{ type: "text", text }], - }); - if (result.error) { - throw result.error instanceof Error ? result.error : new Error(String(result.error)); - } + const nextDraft = buildDraft(text, attachments); + props.onSendDraft(nextDraft); setDraft(""); + setAttachments([]); + props.onDraftChange(buildDraft("", [])); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "Failed to send prompt."); setSending(false); @@ -155,24 +269,181 @@ export function SessionSurface(props: SessionSurfaceProps) { } }, [liveStatus.type]); - const onComposerKeyDown = async (event: React.KeyboardEvent) => { - if (!event.metaKey && !event.ctrlKey) return; - if (event.key !== "Enter") return; - event.preventDefault(); - await handleSend(); + useEffect(() => { + props.onDraftChange(buildDraft(draft, attachments)); + }, [draft, attachments, pasteParts, props]); + + const handleAttachFiles = (files: File[]) => { + if (!props.attachmentsEnabled) { + setNotice({ title: props.attachmentsDisabledReason ?? "Attachments are unavailable.", tone: "warning" }); + return; + } + const oversized = files.filter((file) => file.size > 25 * 1024 * 1024); + const accepted = files.filter((file) => file.size <= 25 * 1024 * 1024); + if (oversized.length) { + setNotice({ + title: oversized.length === 1 ? `${oversized[0]?.name ?? "File"} is too large` : `${oversized.length} files are too large`, + description: "Files over 25 MB were skipped.", + tone: "warning", + }); + } + if (!accepted.length) return; + const next = accepted.map((file) => ({ + id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, + name: file.name, + mimeType: file.type || "application/octet-stream", + size: file.size, + kind: file.type.startsWith("image/") ? "image" as const : "file" as const, + file, + previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, + })); + setAttachments((current) => [...current, ...next]); + setNotice({ + title: next.length === 1 ? `Attached ${next[0]?.name ?? "file"}` : `Attached ${next.length} files`, + tone: "success", + }); }; + const handleRemoveAttachment = (id: string) => { + setAttachments((current) => { + const target = current.find((item) => item.id === id); + if (target?.previewUrl) { + URL.revokeObjectURL(target.previewUrl); + } + return current.filter((item) => item.id !== id); + }); + }; + + const handleInsertMention = (kind: "agent" | "file", value: string) => { + setDraft((current) => current.replace(/@([^\s@]*)$/, `@${value} `)); + setMentions((current) => ({ ...current, [value]: kind })); + }; + + const handlePasteText = (text: string) => { + const id = `paste-${Math.random().toString(36).slice(2)}`; + const label = `${id.slice(-4)} ยท ${text.split(/\r?\n/).length} lines`; + setPasteParts((current) => [...current, { id, label, text, lines: text.split(/\r?\n/).length }]); + setDraft((current) => `${current}[pasted text ${label}]`); + }; + + const handleRevealPastedText = (id: string) => { + const part = pasteParts.find((item) => item.id === id); + if (!part) return; + setNotice({ + title: `Pasted text ยท ${part.label}`, + description: part.text.slice(0, 800), + tone: "info", + }); + }; + + const handleRemovePastedText = (id: string) => { + setPasteParts((current) => { + const target = current.find((item) => item.id === id); + if (!target) return current; + setDraft((draftValue) => draftValue.replace(`[pasted text ${target.label}]`, "")); + return current.filter((item) => item.id !== id); + }); + }; + + const handleUnsupportedFileLinks = (links: string[]) => { + if (!links.length) return; + setDraft((current) => `${current}${current && !current.endsWith("\n") ? "\n" : ""}${links.join("\n")}`); + }; + + const scrollRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const previousSessionIdRef = useRef(props.sessionId); + const messageCountRef = useRef(renderedMessages.length); + + // Save scroll position when leaving a session + useEffect(() => { + const previousId = previousSessionIdRef.current; + if (previousId !== props.sessionId) { + const el = scrollRef.current; + if (el) scrollPositionBySession.set(previousId, el.scrollTop); + previousSessionIdRef.current = props.sessionId; + } + }, [props.sessionId]); + + // Restore scroll position or scroll to bottom on session change + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + const saved = scrollPositionBySession.get(props.sessionId); + if (saved !== undefined) { + el.scrollTop = saved; + shouldAutoScrollRef.current = isNearBottom(el); + } else { + el.scrollTop = el.scrollHeight; + shouldAutoScrollRef.current = true; + } + setShowScrollToBottom(!shouldAutoScrollRef.current); + }, [props.sessionId]); + + // Auto-follow during streaming / new messages + useLayoutEffect(() => { + if (!shouldAutoScrollRef.current) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [renderedMessages.length, chatStreaming]); + + // Also auto-follow when message count changes during streaming + useEffect(() => { + if (renderedMessages.length !== messageCountRef.current) { + messageCountRef.current = renderedMessages.length; + if (shouldAutoScrollRef.current) { + const el = scrollRef.current; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + } + } + }, [renderedMessages.length]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const near = isNearBottom(el); + if (near && !shouldAutoScrollRef.current) { + shouldAutoScrollRef.current = true; + } else if (!near && shouldAutoScrollRef.current) { + shouldAutoScrollRef.current = false; + } + setShowScrollToBottom(!shouldAutoScrollRef.current); + }, []); + + const handleWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + shouldAutoScrollRef.current = false; + setShowScrollToBottom(true); + } + }, []); + + const scrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + shouldAutoScrollRef.current = true; + setShowScrollToBottom(false); + }, []); + return ( -
- {model.transitionState === "switching" ? ( -
+
+ {model.transitionState === "switching" && showDelayedLoading ? ( +
{model.renderSource === "cache" ? "Switching session from cache..." : "Switching session..."}
) : null} - {!snapshot && snapshotQuery.isLoading && renderedMessages.length === 0 ? ( +
+
+ {showDelayedLoading && pendingSessionLoad ? (
Loading React session view...
@@ -193,42 +464,67 @@ export function SessionSurface(props: SessionSurfaceProps) { ) : ( )} - -
-
-