Files
openchamber/packages/ui/src/sync/input-store.ts
T
Bohdan Triapitsyn 1ae3aeff5f feat(chat): structured context attachments with metadata round-trip
Every user-attached context item (diff/file/plan comments, terminal
selections, browser annotations, PR comments and failed checks, linked
issues/PRs, and new chat-quote comments from the selection menu) is now
sent as its own synthetic text part carrying an openchamberContext
metadata payload. The model-facing text keeps the previous wording; the
timeline reads the metadata back and renders each item as a context card
instead of raw prompt text. Legacy messages still render via the old
text sniffing.

The selection menu gains a Comment option with an inline multiline
input, the quoted fragment stays highlighted while commenting, and on
mobile the input overlays the composer pill by rendering inside the
composer form. Add to chat is renamed Add to input; the menu is
restyled and the mobile Copy tile removed. Terminal drafts move their
terminal id out of the language field (persisted-draft migration v3),
and the dead preview-console source is deleted.
2026-08-23 23:40:32 +03:00

317 lines
12 KiB
TypeScript

/**
* Input Store — pending input text, synthetic parts, and attached files.
* Extracted from session-ui-store for subscription isolation.
*/
import { create } from "zustand"
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFiles } from "./attachment-files"
const FILE_URI_PREFIX = "file://"
const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3
const pendingVSCodeSelectionKeys = new Set<string>()
let attachmentReadGeneration = 0
const encodeFilePath = (filepath: string): string => {
let normalized = filepath.replace(/\\/g, "/")
if (/^[A-Za-z]:/.test(normalized)) {
normalized = `/${normalized}`
}
return normalized
.split("/")
.map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
return encodeURIComponent(segment)
})
.join("/")
}
const toFileUrl = (filepath: string): string => {
const normalized = filepath.replace(/\\/g, "/").trim()
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
return normalized
}
return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`
}
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
const hasGeneratedFilenameCollision = (filenames: string[], attachedFiles: AttachedFile[]): boolean => {
if (filenames.length === 0) return false
const attachedFilenames = new Set(attachedFiles.map((attachment) => attachment.filename.toLowerCase()))
return filenames.some((filename) => attachedFilenames.has(filename.toLowerCase()))
}
const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const value = typeof reader.result === "string" ? reader.result : ""
const commaIndex = value.indexOf(",")
resolve(commaIndex === -1 ? value : `data:${mime};base64,${value.slice(commaIndex + 1)}`)
}
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"))
reader.onabort = () => reject(new Error("File read aborted"))
reader.readAsDataURL(file)
})
export const prepareLocalAttachments = async (
file: File,
reservedFilenames: Iterable<string> = [],
): Promise<AttachedFile[] | undefined> => {
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0) return
const sourceDocumentId = preparedFiles.length > 1
? `${Date.now()}-${Math.random().toString(36).slice(2)}`
: undefined
const attachedFiles: AttachedFile[] = []
for (const prepared of preparedFiles) {
const dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
if (!dataUrl) return
attachedFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
sourceDocumentId,
})
}
return attachedFiles
}
const getDataUrlByteSize = (url: string): number => {
if (!url.startsWith("data:")) return 0
const commaIndex = url.indexOf(",")
if (commaIndex < 0) return 0
const metadata = url.slice(0, commaIndex).toLowerCase()
const payload = url.slice(commaIndex + 1)
if (!metadata.endsWith(";base64")) return 0
let padding = 0
if (payload.endsWith("==")) {
padding = 2
} else if (payload.endsWith("=")) {
padding = 1
}
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding)
}
const isSameVSCodeActiveEditorFile = (a: VSCodeActiveEditorFile | null, b: VSCodeActiveEditorFile | null): boolean => {
if (a === b) return true
if (!a || !b) return false
return a.filePath === b.filePath
&& a.fileName === b.fileName
&& a.relativePath === b.relativePath
&& a.fileSize === b.fileSize
&& a.selection?.startLine === b.selection?.startLine
&& a.selection?.endLine === b.selection?.endLine
&& a.selection?.text === b.selection?.text
}
export type SyntheticContextPart = {
text: string
attachments?: AttachedFile[]
synthetic?: boolean
metadata?: ContextPartMetadata
}
export type VSCodeActiveEditorFile = {
filePath: string
fileName: string
relativePath: string
fileSize: number | null
selection: { startLine: number; endLine: number; text: string } | null
}
export type InputState = {
pendingInputText: string | null
pendingInputMode: "replace" | "append" | "append-inline"
pendingSyntheticParts: SyntheticContextPart[] | null
/**
* Text a draft preset chip asked to submit immediately. Set by surfaces that
* render the chips outside ChatInput (e.g. under the welcome message on
* narrow layouts); consumed by ChatInput, which owns the command-aware submit.
*/
pendingPresetSubmit: { text: string; type: "command" | "skill" } | null
attachedFiles: AttachedFile[]
activeEditorFile: VSCodeActiveEditorFile | null
setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
requestPresetSubmit: (text: string, type: "command" | "skill") => void
consumePendingPresetSubmit: () => { text: string; type: "command" | "skill" } | null
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
addAttachedFile: (file: File) => Promise<boolean>
removeAttachedFile: (id: string) => void
setAttachedFiles: (files: AttachedFile[]) => void
clearAttachedFiles: () => void
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
setActiveEditorFile: (file: VSCodeActiveEditorFile | null) => void
/** Add attachments restored from a reverted message (file already on server) */
addRestoredAttachment: (file: { url: string; mimeType: string; filename: string }) => void
}
export const useInputStore = create<InputState>()((set, get) => ({
pendingInputText: null,
pendingInputMode: "replace",
pendingSyntheticParts: null,
pendingPresetSubmit: null,
attachedFiles: [],
activeEditorFile: null,
setPendingInputText: (text, mode = "replace") =>
set({ pendingInputText: text, pendingInputMode: mode }),
consumePendingInputText: () => {
const { pendingInputText, pendingInputMode } = get()
if (pendingInputText === null) return null
set({ pendingInputText: null, pendingInputMode: "replace" })
return { text: pendingInputText, mode: pendingInputMode }
},
requestPresetSubmit: (text, type) => set({ pendingPresetSubmit: { text, type } }),
consumePendingPresetSubmit: () => {
const { pendingPresetSubmit } = get()
if (pendingPresetSubmit === null) return null
set({ pendingPresetSubmit: null })
return pendingPresetSubmit
},
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
consumePendingSyntheticParts: () => {
const { pendingSyntheticParts } = get()
if (pendingSyntheticParts !== null) {
set({ pendingSyntheticParts: null })
}
return pendingSyntheticParts
},
addAttachedFile: async (file: File) => {
const generation = attachmentReadGeneration
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
let attachedFiles: AttachedFile[] | undefined
try {
attachedFiles = await prepareLocalAttachments(file, reservedFilenames)
} catch {
return false
}
if (!attachedFiles || generation !== attachmentReadGeneration) return false
const generatedFilenames = attachedFiles.slice(1).map((attachment) => attachment.filename)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
return true
}
return false
},
removeAttachedFile: (id) =>
set((s) => {
const target = s.attachedFiles.find((f) => f.id === id)
if (target?.sourceDocumentId) {
return { attachedFiles: s.attachedFiles.filter((f) => f.sourceDocumentId !== target.sourceDocumentId) }
}
return { attachedFiles: s.attachedFiles.filter((f) => f.id !== id) }
}),
setAttachedFiles: (files) => {
attachmentReadGeneration += 1
set({ attachedFiles: files })
},
clearAttachedFiles: () => {
attachmentReadGeneration += 1
set({ attachedFiles: [] })
},
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const isDuplicate = get().attachedFiles.some(
(f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === path
)
if (isDuplicate) return
const dataUrl = toFileUrl(path)
// `file://` URLs are the same contract used by server-source attachments.
// The submission path passes `dataUrl` as `url` directly to the OpenCode
// server, which resolves `file://` paths natively. No base64 encoding needed.
const attached: AttachedFile = {
id,
file: new File([], name, { type: 'text/plain' }),
dataUrl,
mimeType: 'text/plain',
filename: name,
size: fileSize || 0,
source: 'vscode',
vscodePath: path,
vscodeSource: 'file',
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
},
addVSCodeSelectionAttachment: async (path: string, file: File) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const generation = attachmentReadGeneration
const selectionKey = getVSCodeSelectionKey(path, file.name)
const isDuplicate = get().attachedFiles.some(
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === file.name && f.vscodePath === path
)
if (isDuplicate || pendingVSCodeSelectionKeys.has(selectionKey)) return
pendingVSCodeSelectionKeys.add(selectionKey)
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(file, file.type)
} catch {
return
} finally {
pendingVSCodeSelectionKeys.delete(selectionKey)
}
if (generation !== attachmentReadGeneration) return
const attached: AttachedFile = {
id,
file,
dataUrl,
mimeType: file.type,
filename: file.name,
size: file.size,
source: 'vscode',
vscodePath: path,
vscodeSource: 'selection',
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
},
setActiveEditorFile: (file) => {
if (isSameVSCodeActiveEditorFile(get().activeEditorFile, file)) return
set({ activeEditorFile: file })
},
addRestoredAttachment: ({ url, mimeType, filename }) => {
const id = `restored-${Date.now()}-${Math.random().toString(36).slice(2)}`
// Use "local" source so the file renders in AttachedFilesList.
// Set serverPath to the URL so ImagePreview can use it as the img src
// when dataUrl is not a data: URL. sanitizeAttachmentsForSend leaves
// dataUrl alone for non-server sources, so the URL stays intact on send.
const attached: AttachedFile = {
id,
file: new File([], filename, { type: mimeType }),
dataUrl: url,
mimeType,
filename,
size: getDataUrlByteSize(url),
source: "local",
serverPath: url,
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
},
}))