From f7cb0d9e0c728d74f46091dd9d30f9a97fd73308 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 15 Sep 2026 13:48:26 +0200 Subject: [PATCH 01/24] Clarify edge and elevation guidance --- DESIGN.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 75057a05..5b3dc738 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -39,9 +39,7 @@ Use `font-sans` for UI, including paths, ids, and numbers. Reserve `font-mono` f ## Layout and Hierarchy -Build hierarchy with type weight, spacing, tonal surfaces, and subtle shadow-defined edges. Use the Tailwind scale and component sizes instead of copying numeric geometry into feature code. - -Use subtle shadows instead of borders for standalone contained blocks and outline-style controls. Keep nested elements flat unless they represent a separate contained object. For interactive elements, let the shadow become one step darker or deeper on hover using the existing shadow scale. Use CSS borders only for separators and hard structural boundaries, and keep them subtle with the semantic `border` token. Do not use black or high-contrast 1px outlines around containers, including dashed outlines. +Build hierarchy with type weight, spacing, and tonal surfaces. Use the Tailwind scale and component sizes instead of copying numeric geometry into feature code. Respect the layout variables in `client/index.css`; do not hardcode equivalent page or chat dimensions. Make layouts work at narrow and wide widths without clipped controls, overlapping text, or large dead areas. @@ -49,6 +47,20 @@ Repeated rows must form stable lanes. Give leading icons, status markers, counte Use container borders sparingly. Avoid cards inside cards and chains of bordered, padded wrappers. A section should usually have at most one framed surface. Inside it, create hierarchy with spacing, typography, tonal surfaces, dividers, or flat rows. Add another frame only when it contains a genuinely separate object, preview, dialog, or tool. +## Edges and Elevation + +Start with no outline or shadow. Add an edge when a boundary needs to be visible. For installed components, keep the treatment built into the chosen variant. For new custom surfaces, use these rules: + +- **Flat, self-contained surface:** use `ring-1 ring-border`. This is the default for a complete outline around an object that sits in the normal layout. The ring follows the radius without taking space from the content or changing its dimensions. +- **Structural edge:** use `border` with the `border` color token. Use side-specific borders for dividers and shared edges, and a full border for dashed boundaries or an edge that must stay inside the element's box. Adjacent regions should share one dividing line. Keep the built-in borders on form controls. +- **Raised surface:** use `shadow-xs` for a slight lift and `shadow-sm` for a more pronounced lift. Use this only when the surface is meant to sit above its surroundings. An ordinary outlined object defaults to `ring-1 ring-border`. +- **Floating surface:** use `shadow-md` for a local overlay, `shadow-lg` for a higher overlay layer, and `shadow-xl` for a modal layer. Follow the installed overlay primitive's level. Size or importance alone does not justify a stronger shadow. +- **Keyboard focus:** preserve the component's focus treatment. For custom controls, use `focus-visible:ring-3 focus-visible:ring-ring/50`. `ring-border` is a quiet resting outline; `ring-ring` uses the focus color. A resting outline does not replace a visible focus state. + +moi's `shadow-*` tokens already include a thin edge as well as blur and offset. Adding a shadow therefore adds elevation even at `shadow-xs`. Do not use shadows just to make an outline visible, or stack a border and resting ring on the same edge. A raised surface normally needs only its shadow; retain combinations already defined by installed primitives. + +Use the same edge treatment for elements with the same role. Isolated existing usages, custom shadows, and decorative inset effects are exceptions, not defaults for new UI. Keep token values in the theme and avoid custom shadow formulas in feature code. + ## Components and Accessibility Check `client/components/ui` and existing feature components before writing styled native markup. Use installed primitives and their built-in variants. Component source is the authority for APIs, sizes, and composition. From 2a2aed5b00201843ded01400e2c25f47af7f00d8 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 15 Sep 2026 14:56:42 +0200 Subject: [PATCH 02/24] Document applet intent APIs --- .../.claude/skills/moi-workspace/SKILL.md | 52 +------ .../moi-workspace/references/INTENTS.md | 137 ++++++++++++++++++ 2 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 workspace/.claude/skills/moi-workspace/references/INTENTS.md diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index 5e43c8ed..5661519c 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -257,53 +257,11 @@ It's plain Bun — every Bun API is available with no setup: `bun:sqlite`, `Bun. Rule of thumb: small own art → `import`; structured data → `.server.ts` returns it; large/streamable media → `.server.ts` returns the **path**, render with `fileUrl()`. -### Driving the workspace — `focusTab` & `sendChatMessage` +### Applet intents -An applet can move the user to another tab and talk to you, through two functions from the **`moi`** -package. - -```tsx -import { focusTab, sendChatMessage } from 'moi' - -// A widget row drilling into a view, and a button that asks you to do something. -focusTab('view:orders', { order: 'o-1024' }) -sendChatMessage('Chase order o-1024', { order: 'o-1024', carrier: 'dhl' }) -``` - -- `focusTab(tab, params?)` switches the workspace to a tab. Tab ids are `overview`, `agent`, - `scratchpad`, and `view:` — run `moi tabs` for the real list. `params` arrive as the target - view's `params` prop. -- `sendChatMessage(message, context?)` sends `message` to the active chat as if the user typed it. - `context` is structured data you see and the user does not. Call it from event handlers, never - during render. Context can contain additional instructions not visible to the user, describing how - the task should be done. -- `params` and `context` accept JSON serializable values only. - -#### Params: the type is the contract - -A view with addressable state declares a local `Params` type in its own file. Every field is -optional and carries a comment, because the view must render sensibly with `{}` — a fresh mount, a -plain tab-bar click, or a new browser tab all deliver nothing. - -```tsx -// .moi/views/orders.tsx -// The view's addressable state — what `focusTab('view:orders', …)` can set. -type Params = { - // Order id to open in the detail pane; omit to show the list. - order?: string -} - -export default function Orders({ params = {} }: { params?: Params }) { - // Values arrive from navigation state, so narrow before trusting them. - const openOrder = typeof params.order === 'string' ? params.order : null - … -} -``` - -**Applets never import from each other, not even types.** Before wiring a `focusTab` call, read the -target view's source, mirror the shape you find there, and note where you read it. That file is the -contract; the type is documentation, not a shared module. Widgets are never navigation targets — -their `params` is always `{}`. +Intents connect applet interactions to other views and the wider workspace through the host API. +When building applet interactions, read [Applet intents](references/INTENTS.md) to see which +behaviors the host supports and when to use them. ### Environment & secrets @@ -479,4 +437,4 @@ This skill is installed with moi (via the CLI or the UI) and can fall behind whe - **Then** — if you updated, mention it. - + diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md new file mode 100644 index 00000000..5343adcc --- /dev/null +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -0,0 +1,137 @@ +# Applet intents + +Intents are functions imported from `moi` that ask the host to navigate or interact with chat. +Use them in user event handlers. Each returns `void`; there is no completion callback or result +promise. moi identifies the source applet automatically for chat actions. +For rejected chat intents, inspect `moi debug logs`. + +## `focusTab(tab, params?)` + +Use this to take the user to another workspace tab or view, optionally passing params. +No chat message is sent. Widgets cannot be navigation targets; their `params` is always `{}`. + +### API + +```ts +function focusTab(tab: string, params?: Record): void +``` + +- `tab`: required workspace tab id. Use `overview`, `scratchpad`, or `view:`. + Run `moi tabs` to discover available tabs. +- `params`: optional JSON object describing the target view's addressable state. It arrives + through that view's `params` prop. + +### Example + +```tsx +import { focusTab } from 'moi' + + +``` + +### View params contract + +A view with addressable state declares a local `Params` type in its own file. Every field is +optional and carries a comment, because the view must render sensibly with `{}` — a fresh mount, a +plain tab-bar click, or a new browser tab all deliver nothing. + +```tsx +// .moi/views/orders.tsx +// The view's addressable state — what `focusTab('view:orders', …)` can set. +type Params = { + // Order id to open in the detail pane; omit to show the list. + order?: string +} + +export default function Orders({ params = {} }: { params?: Params }) { + // Values arrive from navigation state, so narrow before trusting them. + const openOrder = typeof params.order === 'string' ? params.order : null + … +} +``` + +**Applets never import from each other, not even types.** Before wiring a `focusTab` call, read the +target view's source, mirror the shape you find there, and note where you read it. That file is the +contract; the type is documentation, not a shared module. + +## `addChatContext({ label, context })` + +Use this when the action may need the user's input or modification before sending. It adds +a removable chip to the current draft, opens chat, and focuses the composer, keeping existing +text intact. The user can write or revise their message, add more attachments, remove chips, +or send context on its own. + +### API + +```ts +function addChatContext(input: { + label: string + context: Record +}): void +``` + +- `label`: required non-empty label, up to 120 characters, shown on the attachment chip. +- `context`: required plain JSON object, up to 5,000 serialized characters. Include the data the + agent needs to understand the item. + +### Example + +```tsx +import { addChatContext } from 'moi' + + +``` + +### Limits + +Data is validated and copied at click time. The snapshot cannot be edited in the composer, +and later applet changes do not affect it. +Invalid data rejects the whole call. Identical source, label, and JSON payloads are skipped. +Context items can be staged alongside files and drawings. + +Staging works while the agent is busy or unavailable. Draft attachments stay with their chat +in memory and are lost on page reload. No file is uploaded. + +## `sendChatMessage({ message, context? })` + +Use this for a complete, ready-to-run action when the user is not expected to add or change +anything before it starts. It opens chat and sends immediately to the active chat without +consuming staged draft attachments. Use action message copy that makes the immediate send clear. + +### API + +```ts +function sendChatMessage(input: { message: string; context?: Record }): void +``` + +- `message`: required user-visible message. Whitespace is trimmed; empty messages are ignored. + Maximum length: 1,000 characters. +- `context`: optional JSON object with data or task instructions for the agent, hidden from the + visible message text. Maximum serialized length: 2,000 characters. + +### Example + +```tsx +import { sendChatMessage } from 'moi' + + +``` + +### Limits + +An oversized message is rejected. Invalid or oversized context is omitted while the message +still sends. Identical messages from the same applet within two seconds are dropped; the +workspace allows up to 10 applet sends per minute. Calls are also dropped when the agent is +unavailable. From 6d087d04ceec0dc7fc6b14cdb9b372bf9beeed48 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 15 Sep 2026 15:15:35 +0200 Subject: [PATCH 03/24] Support structured context attachments in chat messages --- client/features/views/api.ts | 3 ++- lib/format.ts | 3 +++ lib/types.ts | 9 ++++++++- server/api.ts | 18 ++++++++---------- server/harness/types.ts | 6 +++--- server/moi-scaffold.ts | 4 +++- server/view-builders.ts | 2 +- server/web.ts | 4 ++-- 8 files changed, 30 insertions(+), 19 deletions(-) diff --git a/client/features/views/api.ts b/client/features/views/api.ts index 6fdad233..683c76af 100644 --- a/client/features/views/api.ts +++ b/client/features/views/api.ts @@ -1,3 +1,4 @@ +import type { MessageAttachment } from '@/lib/types' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { jsonRequest, requestJson, requestVoid } from '@/client/api/http' @@ -113,7 +114,7 @@ export type SubmitViewBuilderInput = { builderId: string requirements: string optimisticId: string - attachments?: string[] + attachments?: MessageAttachment[] model?: string effort?: string fastMode?: boolean diff --git a/lib/format.ts b/lib/format.ts index d31f0838..afcf442e 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,3 +1,5 @@ +import type { ContextAttachment } from './types' + // Display-only conversation format. Agent-agnostic. // Source: server/harness/README.md (format section; formerly claude-code-messages.md §14) (and the research PDF under dev/report). // Built at ingest time from an agent's raw stream (today: Claude Agent SDK). @@ -50,6 +52,7 @@ export type ToolCall = { export type Citation = { url?: string; title?: string; quote?: string } export type Part = + | ({ type: 'context' } & ContextAttachment) | { type: 'text'; text: string; citations?: Citation[] } // `redacted` marks reasoning the backend acknowledges but won't hand over: // Anthropic's `redacted_thinking`, and the codex app-server's `Reasoning` diff --git a/lib/types.ts b/lib/types.ts index 9056132c..0a3d2d20 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -208,6 +208,13 @@ export type UploadInfo = { // Client → Server messages. // The chat WebSocket is app-wide (one socket for the whole client, not scoped to // a workspace), so every message carries the `workspaceId` it targets. +export type ChatContextInput = { label: string; context: Record } +export type ContextAttachment = ChatContextInput & { source: string } + +export type MessageAttachment = + | { type: 'upload'; uploadId: string } + | ({ type: 'context' } & ContextAttachment) + export type ClientMessage = | { type: 'chat' @@ -218,7 +225,7 @@ export type ClientMessage = // Upload ids (from POST .../uploads) to attach to this turn. The server // resolves each from its upload store and turns it into a vision block // (image) or a temp-file path reference (other files). Order is preserved. - attachments?: string[] + attachments?: MessageAttachment[] // Client-chosen stable id for the user's turn. The server tells the // adapter to use this id when the SDK echoes the user input back, so // the optimistic bubble the client rendered gets upserted in place. diff --git a/server/api.ts b/server/api.ts index 3dccdf5a..bfd6f6c5 100644 --- a/server/api.ts +++ b/server/api.ts @@ -1,3 +1,4 @@ +import { isMessageAttachments } from '@/lib/message-attachments' import { Hono } from 'hono' import { serveStatic } from 'hono/bun' import { createMiddleware } from 'hono/factory' @@ -313,15 +314,12 @@ one.post('/view-builders/:builderId/submit', async c => { const availableIcons = parseAvailableViewIcons(body.availableIcons) if (!availableIcons) return c.text('Available view icons are required', 400) const attachments = body.attachments ?? [] - if ( - !Array.isArray(attachments) || - attachments.length > 1 || - !attachments.every(id => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id)) - ) { - return c.text('Invalid sketch attachment', 400) - } - if (attachments.some(id => getUpload(ws.id, id)?.kind !== 'image')) { - return c.text('Sketch attachment not found or expired', 400) + if (!isMessageAttachments(attachments)) return c.text('Invalid attachments', 400) + for (const attachment of attachments) { + if (attachment.type !== 'upload') continue + if (!/^[a-f0-9]{64}$/.test(attachment.uploadId)) return c.text('Invalid upload id', 400) + if (!getUpload(ws.id, attachment.uploadId)) + return c.text('Attachment not found or expired', 400) } const availability = await workspaceTypeAvailability(ws.type ?? 'claude-code') if (availability.status !== 'available') return c.text(availability.reason, 400) @@ -341,7 +339,7 @@ one.post('/view-builders/:builderId/submit', async c => { directives: [ ...viewBuilderDirectives(builder.id, availableIcons), ...(attachments.length > 0 - ? ["The attached image is the user's sketch of the intended view layout."] + ? ['Use the attachments as reference material for the intended view.'] : []) ] } diff --git a/server/harness/types.ts b/server/harness/types.ts index 3fdc5c10..4fe713b2 100644 --- a/server/harness/types.ts +++ b/server/harness/types.ts @@ -2,6 +2,7 @@ // by moi. See README.md in this folder for the full checklist and the // message-type layers; each harness's index.ts exports one Harness object // wrapping its session/adapter/transport modules. +import type { MessageAttachment } from '@/lib/types' import type { MoiContext } from '@/lib/moi-context' import type { HarnessAvailability, @@ -26,9 +27,8 @@ export type SendMessageInput = { sessionId: string isNew: boolean content: string - // Upload ids from POST .../uploads, resolved by the harness into its input - // capability (base64 blocks / data URLs / temp-file path notes). - attachments?: string[] + // Uploaded files and drawings, or structured context from applets. + attachments?: MessageAttachment[] optimisticId?: string model?: string effort?: string diff --git a/server/moi-scaffold.ts b/server/moi-scaffold.ts index 885c9ada..87aceaef 100644 --- a/server/moi-scaffold.ts +++ b/server/moi-scaffold.ts @@ -102,12 +102,14 @@ declare module 'moi' { // target view as its \`params\` prop — JSON-plain values only. No-ops outside // the moi host. Tab ids: 'overview' | 'agent' | 'scratchpad' | 'view:'. export function focusTab(tab: string, params?: Record): void + // Stage context in the current chat draft; does not send a message. + export function addChatContext(input: { label: string; context: Record }): void // Send a chat message to the workspace's active chat, as if the user typed // \`message\`. \`context\` rides along as structured data the agent sees but // the user does not — JSON-plain values only. Call it from event handlers, // never during render: each call starts an agent run, and repeats are // rate-limited. - export function sendChatMessage(message: string, context?: Record): void + export function sendChatMessage(input: { message: string; context?: Record }): void export type WidgetConfig = { rowSpan: 1 | 2 | 3 | 4 colSpan: 1 | 2 | 3 | 4 diff --git a/server/view-builders.ts b/server/view-builders.ts index 237d9aef..2436c34f 100644 --- a/server/view-builders.ts +++ b/server/view-builders.ts @@ -151,7 +151,7 @@ export async function beginViewBuilder( ): Promise { const text = requirements.trim() if (!text && !hasSketch) - throw new ViewBuilderError('View requirements or a sketch are required', 400) + throw new ViewBuilderError('View requirements or an attachment are required', 400) const builder = await mutateBuilders(workspacePath, builders => { const [current, index] = findBuilder(builders, builderId) if (current.status !== 'draft') { diff --git a/server/web.ts b/server/web.ts index a7f8e9a1..78f2ac66 100644 --- a/server/web.ts +++ b/server/web.ts @@ -1,3 +1,4 @@ +import { isMessageAttachments } from '@/lib/message-attachments' import type { ClientMessage, StatusSnapshotMessage } from '@/lib/types' import { isMoiContext } from '@/lib/moi-context' @@ -50,8 +51,7 @@ function isClientMessage(value: unknown): value is ClientMessage { (v.fastMode === undefined || typeof v.fastMode === 'boolean') && (v.stream === undefined || typeof v.stream === 'boolean') && (v.context === undefined || isMoiContext(v.context)) && - (v.attachments === undefined || - (Array.isArray(v.attachments) && v.attachments.every(a => typeof a === 'string'))) + (v.attachments === undefined || isMessageAttachments(v.attachments)) ) if (v.type === 'stop') return typeof v.workspaceId === 'string' && typeof v.sessionId === 'string' if (v.type === 'scratchpad:op-result') return typeof v.opId === 'string' From 14c4222c08d669321875c60ee26ca13225a7d0b6 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 15 Sep 2026 16:03:46 +0200 Subject: [PATCH 04/24] Add reusable attachment chip components --- .../composer/attachments/AttachmentChip.tsx | 123 ++++++++++++++++++ .../attachments/ContextAttachmentChip.tsx | 11 ++ .../attachments/DrawingAttachmentChip.tsx | 37 ++++++ .../attachments/FileAttachmentChip.tsx | 22 ++++ 4 files changed, 193 insertions(+) create mode 100644 client/features/chat/composer/attachments/AttachmentChip.tsx create mode 100644 client/features/chat/composer/attachments/ContextAttachmentChip.tsx create mode 100644 client/features/chat/composer/attachments/DrawingAttachmentChip.tsx create mode 100644 client/features/chat/composer/attachments/FileAttachmentChip.tsx diff --git a/client/features/chat/composer/attachments/AttachmentChip.tsx b/client/features/chat/composer/attachments/AttachmentChip.tsx new file mode 100644 index 00000000..af69f1a2 --- /dev/null +++ b/client/features/chat/composer/attachments/AttachmentChip.tsx @@ -0,0 +1,123 @@ +import type { ComponentPropsWithRef } from 'react' +import { IconLoader2, IconX, type Icon } from '@tabler/icons-react' +import { Button } from '@/client/components/ui/button' +import { cn } from '@/client/lib/cn' + +type AttachmentChipProps = ComponentPropsWithRef<'div'> & { + label: string + icon: Icon + onRemove?: () => void + previewUrl?: string + status?: 'draft' | 'uploading' | 'ready' | 'error' +} + +// Two layouts share one frame: a labelled chip or an image thumbnail. +// Upload status is an overlay; it doesn't introduce another layout. +export function AttachmentChip({ + label, + icon, + onRemove, + previewUrl, + status, + className, + children, + ...props +}: AttachmentChipProps) { + return ( +
+ {previewUrl ? ( + + ) : ( + + )} + {children} +
+ ) +} + +type LabelChipContentProps = Pick + +function LabelChipContent({ label, icon: IconComponent, onRemove, status }: LabelChipContentProps) { + return ( + <> + {onRemove ? ( + + ) : ( + + + + )} + {label} + + + ) +} + +type ImageChipContentProps = Pick & { + previewUrl: string +} + +function ImageChipContent({ label, previewUrl, onRemove, status }: ImageChipContentProps) { + return ( + <> + {label} + + {onRemove && ( + + )} + + ) +} + +type ChipUploadStatusProps = Pick + +function ChipUploadStatus({ status }: ChipUploadStatusProps) { + if (status === 'uploading') { + return ( +
+ +
+ ) + } + if (status === 'error') { + return ( +
+ Failed +
+ ) + } + return null +} diff --git a/client/features/chat/composer/attachments/ContextAttachmentChip.tsx b/client/features/chat/composer/attachments/ContextAttachmentChip.tsx new file mode 100644 index 00000000..b248207f --- /dev/null +++ b/client/features/chat/composer/attachments/ContextAttachmentChip.tsx @@ -0,0 +1,11 @@ +import { IconLibrary } from '@tabler/icons-react' +import { AttachmentChip } from './AttachmentChip' + +type ContextAttachmentChipProps = { + label: string + onRemove?: () => void +} + +export function ContextAttachmentChip({ label, onRemove }: ContextAttachmentChipProps) { + return +} diff --git a/client/features/chat/composer/attachments/DrawingAttachmentChip.tsx b/client/features/chat/composer/attachments/DrawingAttachmentChip.tsx new file mode 100644 index 00000000..2cd24e8d --- /dev/null +++ b/client/features/chat/composer/attachments/DrawingAttachmentChip.tsx @@ -0,0 +1,37 @@ +import { IconScribble } from '@tabler/icons-react' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/client/components/ui/hover-card' +import type { ChatAttachment } from './types' +import { AttachmentChip } from './AttachmentChip' + +type DrawingAttachmentChipProps = { + attachment: Extract + onRemove: () => void +} + +export function DrawingAttachmentChip({ attachment, onRemove }: DrawingAttachmentChipProps) { + const { previewUrl, purpose } = attachment + const label = purpose === 'sketch' ? 'Sketch' : 'Annotation' + + return ( + + + } + /> + {previewUrl && ( + + {`${label} + + )} + + ) +} diff --git a/client/features/chat/composer/attachments/FileAttachmentChip.tsx b/client/features/chat/composer/attachments/FileAttachmentChip.tsx new file mode 100644 index 00000000..395705fb --- /dev/null +++ b/client/features/chat/composer/attachments/FileAttachmentChip.tsx @@ -0,0 +1,22 @@ +import { IconFile } from '@tabler/icons-react' +import type { ChatAttachment } from './types' +import { AttachmentChip } from './AttachmentChip' + +type FileAttachmentChipProps = { + attachment: Extract + onRemove: () => void +} + +export function FileAttachmentChip({ attachment, onRemove }: FileAttachmentChipProps) { + return ( + + ) +} From 0a80a316b4a9de4ba2fb6dd0bf5b755864c11096 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 15 Sep 2026 16:04:48 +0200 Subject: [PATCH 05/24] Remove redundant attachment chip tooltip --- client/features/chat/composer/attachments/AttachmentChip.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/features/chat/composer/attachments/AttachmentChip.tsx b/client/features/chat/composer/attachments/AttachmentChip.tsx index af69f1a2..25f30e77 100644 --- a/client/features/chat/composer/attachments/AttachmentChip.tsx +++ b/client/features/chat/composer/attachments/AttachmentChip.tsx @@ -25,7 +25,6 @@ export function AttachmentChip({ }: AttachmentChipProps) { return (
Date: Wed, 16 Sep 2026 14:20:13 +0200 Subject: [PATCH 06/24] refactor: organize chat files by responsibility --- client/app/AppRouter.tsx | 2 +- client/app/routes/WorkspaceRoute.tsx | 2 +- client/components/TurnParts.render.test.tsx | 2 +- client/features/chat/ChatPanel.tsx | 14 +++++++------- client/features/chat/api.ts | 2 +- client/features/chat/chat-activity.test.ts | 2 +- client/features/chat/composer/ChatComposer.tsx | 2 +- .../attachments}/attachment-staging.test.ts | 2 +- .../attachments}/attachment-staging.ts | 2 +- .../chat/{ => composer/attachments}/uploads.ts | 0 .../chat/{ => connection}/chat-connection.ts | 2 +- .../features/chat/{ => connection}/chat-frames.ts | 4 ++-- .../chat/{ => messages}/ChatEmptyState.test.tsx | 2 +- .../chat/{ => messages}/ChatEmptyState.tsx | 2 +- .../chat/{ => messages}/ChatPromptBubbles.test.tsx | 2 +- .../chat/{ => messages}/ChatPromptBubbles.tsx | 0 .../{ => messages}/ExploreWorkspaceState.test.tsx | 2 +- .../chat/{ => messages}/MarkdownContent.tsx | 0 .../features/chat/{ => messages}/ShimmerText.tsx | 0 client/features/chat/{ => messages}/TurnView.tsx | 2 +- .../chat/{ => messages}/WelcomeState.test.tsx | 2 +- .../chat/{ => messages}/group-turns.test.ts | 2 +- client/features/chat/{ => messages}/group-turns.ts | 0 .../chat/{ => messages}/interleave-notices.test.ts | 2 +- .../chat/{ => messages}/interleave-notices.ts | 0 .../chat/{ => messages}/preview-turn.test.ts | 4 ++-- .../features/chat/{ => messages}/preview-turn.ts | 0 .../chat/{ => messages}/useStickToBottom.ts | 0 .../chat/{ => sessions}/ChatSelector.test.ts | 2 +- .../features/chat/{ => sessions}/ChatSelector.tsx | 4 ++-- .../chat/{ => sessions}/session-view.test.ts | 2 +- .../features/chat/{ => sessions}/session-view.ts | 0 .../chat/{ => sessions}/useSelectedSession.test.ts | 2 +- .../chat/{ => sessions}/useSelectedSession.ts | 0 client/features/chat/tool-group/SubagentCard.tsx | 2 +- client/features/chat/tool-group/ToolCallGroup.tsx | 2 +- client/features/chat/useChat.ts | 6 +++--- client/features/dev/ChatStatesPage.tsx | 10 +++++----- client/features/drawings/useChatAnnotation.ts | 2 +- client/features/drawings/useViewBuilderSketch.ts | 2 +- client/features/views/useViewBuilderActions.ts | 2 +- client/features/workspace/WorkspaceScreen.tsx | 2 +- client/lib/connection.stream.test.ts | 2 +- client/main.tsx | 2 +- 44 files changed, 49 insertions(+), 49 deletions(-) rename client/features/chat/{ => composer/attachments}/attachment-staging.test.ts (99%) rename client/features/chat/{ => composer/attachments}/attachment-staging.ts (99%) rename client/features/chat/{ => composer/attachments}/uploads.ts (100%) rename client/features/chat/{ => connection}/chat-connection.ts (97%) rename client/features/chat/{ => connection}/chat-frames.ts (99%) rename client/features/chat/{ => messages}/ChatEmptyState.test.tsx (98%) rename client/features/chat/{ => messages}/ChatEmptyState.tsx (99%) rename client/features/chat/{ => messages}/ChatPromptBubbles.test.tsx (94%) rename client/features/chat/{ => messages}/ChatPromptBubbles.tsx (100%) rename client/features/chat/{ => messages}/ExploreWorkspaceState.test.tsx (98%) rename client/features/chat/{ => messages}/MarkdownContent.tsx (100%) rename client/features/chat/{ => messages}/ShimmerText.tsx (100%) rename client/features/chat/{ => messages}/TurnView.tsx (99%) rename client/features/chat/{ => messages}/WelcomeState.test.tsx (99%) rename client/features/chat/{ => messages}/group-turns.test.ts (99%) rename client/features/chat/{ => messages}/group-turns.ts (100%) rename client/features/chat/{ => messages}/interleave-notices.test.ts (99%) rename client/features/chat/{ => messages}/interleave-notices.ts (100%) rename client/features/chat/{ => messages}/preview-turn.test.ts (98%) rename client/features/chat/{ => messages}/preview-turn.ts (100%) rename client/features/chat/{ => messages}/useStickToBottom.ts (100%) rename client/features/chat/{ => sessions}/ChatSelector.test.ts (98%) rename client/features/chat/{ => sessions}/ChatSelector.tsx (99%) rename client/features/chat/{ => sessions}/session-view.test.ts (99%) rename client/features/chat/{ => sessions}/session-view.ts (100%) rename client/features/chat/{ => sessions}/useSelectedSession.test.ts (98%) rename client/features/chat/{ => sessions}/useSelectedSession.ts (100%) diff --git a/client/app/AppRouter.tsx b/client/app/AppRouter.tsx index 56cbe0d4..7f31bb6e 100644 --- a/client/app/AppRouter.tsx +++ b/client/app/AppRouter.tsx @@ -2,7 +2,7 @@ import { Suspense, lazy, useEffect } from 'react' import { Redirect, Route, Switch, useLocation } from 'wouter' -import { setWorkspaceSwitchHandler } from '@/client/features/chat/chat-connection' +import { setWorkspaceSwitchHandler } from '@/client/features/chat/connection/chat-connection' import { HomeRoute } from './routes/HomeRoute' import { WorkspaceRoute } from './routes/WorkspaceRoute' diff --git a/client/app/routes/WorkspaceRoute.tsx b/client/app/routes/WorkspaceRoute.tsx index 74a40032..4a2432c7 100644 --- a/client/app/routes/WorkspaceRoute.tsx +++ b/client/app/routes/WorkspaceRoute.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' import { LedLogo } from '@/client/components/shared/LedLogo' import { SidebarLayout } from '@/client/app/shell/SidebarLayout' -import { useSelectedSession } from '@/client/features/chat/useSelectedSession' +import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { useAppletCacheInvalidation } from '@/client/features/applets/useApplet' import { Workspace } from '@/client/features/workspace/WorkspaceContext' import { diff --git a/client/components/TurnParts.render.test.tsx b/client/components/TurnParts.render.test.tsx index 5a48e549..f05ecf64 100644 --- a/client/components/TurnParts.render.test.tsx +++ b/client/components/TurnParts.render.test.tsx @@ -15,7 +15,7 @@ import { AssistantTurnParts, TurnParts, splitCompletedAssistantParts -} from '@/client/features/chat/TurnView' +} from '@/client/features/chat/messages/TurnView' import type { Part } from '@/lib/types' const render = (parts: Part[], processing: boolean) => diff --git a/client/features/chat/ChatPanel.tsx b/client/features/chat/ChatPanel.tsx index 624ea928..ee42ddcb 100644 --- a/client/features/chat/ChatPanel.tsx +++ b/client/features/chat/ChatPanel.tsx @@ -4,20 +4,20 @@ import { IconChevronDown, IconChevronsRight, IconX } from '@tabler/icons-react' import { canSubmitComposerAction, focusComposer } from '@/client/components/shared/Composer' import { AgentBlobatar } from '@/client/components/shared/AgentBlobatar' -import { useStickToBottom } from '@/client/features/chat/useStickToBottom' -import { appendPreviewTurn, groupTurns } from '@/client/features/chat/group-turns' -import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/interleave-notices' +import { useStickToBottom } from '@/client/features/chat/messages/useStickToBottom' +import { appendPreviewTurn, groupTurns } from '@/client/features/chat/messages/group-turns' +import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/messages/interleave-notices' import { attachmentKey, useLive } from '@/client/features/chat/chat-store' -import type { ChatPromptBubble } from '@/client/features/chat/ChatPromptBubbles' +import type { ChatPromptBubble } from '@/client/features/chat/messages/ChatPromptBubbles' import type { ChatSendOptions } from '@/client/features/chat/chat-send' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import type { AgentTheme, SystemNotice, Turn, ViewState } from '@/lib/types' import type { ComposerBanner } from './composer/banners/ComposerBanner' import { ChatComposer, type ComposerAnnotationControls } from './composer/ChatComposer' -import { ChatEmptyState, type WelcomeDestination, resolveChatEmptyState } from './ChatEmptyState' -import { ChatSelector } from './ChatSelector' -import { TurnView } from './TurnView' +import { ChatEmptyState, type WelcomeDestination, resolveChatEmptyState } from './messages/ChatEmptyState' +import { ChatSelector } from './sessions/ChatSelector' +import { TurnView } from './messages/TurnView' import { Button } from '@/client/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { cn } from '@/client/lib/cn' diff --git a/client/features/chat/api.ts b/client/features/chat/api.ts index ddbe5fb7..582e6a4e 100644 --- a/client/features/chat/api.ts +++ b/client/features/chat/api.ts @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { jsonRequest, requestJson, requestVoid } from '@/client/api/http' import { WORKSPACE_RESOURCE_OPTIONS } from '@/client/api/query-options' import { workspaceKeys } from '@/client/api/workspace-keys' -import { sessionViewOptions } from '@/client/features/chat/session-view' +import { sessionViewOptions } from '@/client/features/chat/sessions/session-view' import type { SessionConfig, SessionInfo } from '@/lib/types' export function useWorkspaceSessions(workspaceId: string) { diff --git a/client/features/chat/chat-activity.test.ts b/client/features/chat/chat-activity.test.ts index 3ee8d451..394904f8 100644 --- a/client/features/chat/chat-activity.test.ts +++ b/client/features/chat/chat-activity.test.ts @@ -6,7 +6,7 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, test } from 'bun:test' import { appUiKeys } from '@/client/api/app-ui-keys' -import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/chat-connection' +import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/connection/chat-connection' import { hasRunningActivity, hasRunningBackgroundSession, diff --git a/client/features/chat/composer/ChatComposer.tsx b/client/features/chat/composer/ChatComposer.tsx index 63749450..ec1fd9a8 100644 --- a/client/features/chat/composer/ChatComposer.tsx +++ b/client/features/chat/composer/ChatComposer.tsx @@ -19,7 +19,7 @@ import { import { Button } from '@/client/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/client/components/ui/hover-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' -import { stageComposerFiles } from '@/client/features/chat/attachment-staging' +import { stageComposerFiles } from '@/client/features/chat/composer/attachments/attachment-staging' import { cn } from '@/client/lib/cn' import type { AgentAvailability } from '@/client/lib/agent-availability' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' diff --git a/client/features/chat/attachment-staging.test.ts b/client/features/chat/composer/attachments/attachment-staging.test.ts similarity index 99% rename from client/features/chat/attachment-staging.test.ts rename to client/features/chat/composer/attachments/attachment-staging.test.ts index 2b45f171..5b5c75c3 100644 --- a/client/features/chat/attachment-staging.test.ts +++ b/client/features/chat/composer/attachments/attachment-staging.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' import { stageDrawing, stageDrawingDraft } from './attachment-staging' -import { attachmentKey, liveStore } from './chat-store' +import { attachmentKey, liveStore } from '../../chat-store' const workspaceId = 'workspace-1' const sessionId = 'session-1' diff --git a/client/features/chat/attachment-staging.ts b/client/features/chat/composer/attachments/attachment-staging.ts similarity index 99% rename from client/features/chat/attachment-staging.ts rename to client/features/chat/composer/attachments/attachment-staging.ts index 49eb4c98..c6987dcb 100644 --- a/client/features/chat/attachment-staging.ts +++ b/client/features/chat/composer/attachments/attachment-staging.ts @@ -1,6 +1,6 @@ import type { WorkspaceTabId } from '@/lib/types' -import { attachmentKey, type ChatAttachment, type DrawingPurpose, liveStore } from './chat-store' +import { attachmentKey, type ChatAttachment, type DrawingPurpose, liveStore } from '../../chat-store' import { uploadFiles } from './uploads' type ComposerTarget = { diff --git a/client/features/chat/uploads.ts b/client/features/chat/composer/attachments/uploads.ts similarity index 100% rename from client/features/chat/uploads.ts rename to client/features/chat/composer/attachments/uploads.ts diff --git a/client/features/chat/chat-connection.ts b/client/features/chat/connection/chat-connection.ts similarity index 97% rename from client/features/chat/chat-connection.ts rename to client/features/chat/connection/chat-connection.ts index be9777f5..3712fa70 100644 --- a/client/features/chat/chat-connection.ts +++ b/client/features/chat/connection/chat-connection.ts @@ -2,7 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import { wsUrl } from '@/client/lib/ws-url' import { liveStore } from '@/client/features/chat/chat-store' -import { reduceChatFrame } from '@/client/features/chat/chat-frames' +import { reduceChatFrame } from '@/client/features/chat/connection/chat-frames' import type { ClientMessage } from '@/lib/types' // App-wide chat connection: ONE WebSocket for the whole client, opened once at diff --git a/client/features/chat/chat-frames.ts b/client/features/chat/connection/chat-frames.ts similarity index 99% rename from client/features/chat/chat-frames.ts rename to client/features/chat/connection/chat-frames.ts index 641d73d8..26a2d14e 100644 --- a/client/features/chat/chat-frames.ts +++ b/client/features/chat/connection/chat-frames.ts @@ -3,8 +3,8 @@ import type { QueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' import { getScratchExecutor } from '@/client/features/scratchpad/scratch-executor' import { liveStore } from '@/client/features/chat/chat-store' -import { bufferSessionEvent } from '@/client/features/chat/session-view' -import { renameSelectedSessionInCache } from '@/client/features/chat/useSelectedSession' +import { bufferSessionEvent } from '@/client/features/chat/sessions/session-view' +import { renameSelectedSessionInCache } from '@/client/features/chat/sessions/useSelectedSession' import { applyEvent } from '@/lib/format' import type { ClientMessage, diff --git a/client/features/chat/ChatEmptyState.test.tsx b/client/features/chat/messages/ChatEmptyState.test.tsx similarity index 98% rename from client/features/chat/ChatEmptyState.test.tsx rename to client/features/chat/messages/ChatEmptyState.test.tsx index 04d53af9..a8f5f443 100644 --- a/client/features/chat/ChatEmptyState.test.tsx +++ b/client/features/chat/messages/ChatEmptyState.test.tsx @@ -7,7 +7,7 @@ import { ChatEmptyState, type ChatEmptyStateKind, resolveChatEmptyState -} from '@/client/features/chat/ChatEmptyState' +} from '@/client/features/chat/messages/ChatEmptyState' function renderState(kind: ChatEmptyStateKind, hasWorkspaceApplets = false): string { return renderToStaticMarkup( diff --git a/client/features/chat/ChatEmptyState.tsx b/client/features/chat/messages/ChatEmptyState.tsx similarity index 99% rename from client/features/chat/ChatEmptyState.tsx rename to client/features/chat/messages/ChatEmptyState.tsx index a36b9289..82b9723e 100644 --- a/client/features/chat/ChatEmptyState.tsx +++ b/client/features/chat/messages/ChatEmptyState.tsx @@ -17,7 +17,7 @@ import { ChatPromptBubble, ChatPromptBubbles, type ChatPromptBubble as ChatPrompt -} from '@/client/features/chat/ChatPromptBubbles' +} from '@/client/features/chat/messages/ChatPromptBubbles' import { cn } from '@/client/lib/cn' import type { AgentTheme } from '@/lib/types' diff --git a/client/features/chat/ChatPromptBubbles.test.tsx b/client/features/chat/messages/ChatPromptBubbles.test.tsx similarity index 94% rename from client/features/chat/ChatPromptBubbles.test.tsx rename to client/features/chat/messages/ChatPromptBubbles.test.tsx index 24618d2b..fb9bec62 100644 --- a/client/features/chat/ChatPromptBubbles.test.tsx +++ b/client/features/chat/messages/ChatPromptBubbles.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, mock, test } from 'bun:test' import { ChatPromptBubble, type ChatPromptBubble as ChatPrompt -} from '@/client/features/chat/ChatPromptBubbles' +} from '@/client/features/chat/messages/ChatPromptBubbles' const prompt: ChatPrompt = { label: 'Build a synthesizer', diff --git a/client/features/chat/ChatPromptBubbles.tsx b/client/features/chat/messages/ChatPromptBubbles.tsx similarity index 100% rename from client/features/chat/ChatPromptBubbles.tsx rename to client/features/chat/messages/ChatPromptBubbles.tsx diff --git a/client/features/chat/ExploreWorkspaceState.test.tsx b/client/features/chat/messages/ExploreWorkspaceState.test.tsx similarity index 98% rename from client/features/chat/ExploreWorkspaceState.test.tsx rename to client/features/chat/messages/ExploreWorkspaceState.test.tsx index f1173afd..18ca4338 100644 --- a/client/features/chat/ExploreWorkspaceState.test.tsx +++ b/client/features/chat/messages/ExploreWorkspaceState.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'bun:test' -import { ChatEmptyState, WORKSPACE_ANALYSIS_PROMPT } from '@/client/features/chat/ChatEmptyState' +import { ChatEmptyState, WORKSPACE_ANALYSIS_PROMPT } from '@/client/features/chat/messages/ChatEmptyState' import { renderMoiContext } from '@/lib/moi-context' function renderExploreWorkspace(disabled = false): string { diff --git a/client/features/chat/MarkdownContent.tsx b/client/features/chat/messages/MarkdownContent.tsx similarity index 100% rename from client/features/chat/MarkdownContent.tsx rename to client/features/chat/messages/MarkdownContent.tsx diff --git a/client/features/chat/ShimmerText.tsx b/client/features/chat/messages/ShimmerText.tsx similarity index 100% rename from client/features/chat/ShimmerText.tsx rename to client/features/chat/messages/ShimmerText.tsx diff --git a/client/features/chat/TurnView.tsx b/client/features/chat/messages/TurnView.tsx similarity index 99% rename from client/features/chat/TurnView.tsx rename to client/features/chat/messages/TurnView.tsx index bf8d1dcd..c34e6727 100644 --- a/client/features/chat/TurnView.tsx +++ b/client/features/chat/messages/TurnView.tsx @@ -9,7 +9,7 @@ import { CollapsibleContent, CollapsibleTrigger } from '@/client/components/ui/collapsible' -import { MarkdownContent } from '@/client/features/chat/MarkdownContent' +import { MarkdownContent } from '@/client/features/chat/messages/MarkdownContent' import { ToolCallGroup } from '@/client/features/chat/tool-group/ToolCallGroup' import { formatDuration } from '@/client/features/chat/tool-group/format' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' diff --git a/client/features/chat/WelcomeState.test.tsx b/client/features/chat/messages/WelcomeState.test.tsx similarity index 99% rename from client/features/chat/WelcomeState.test.tsx rename to client/features/chat/messages/WelcomeState.test.tsx index d06d1bb2..0dd384ad 100644 --- a/client/features/chat/WelcomeState.test.tsx +++ b/client/features/chat/messages/WelcomeState.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'bun:test' -import { CHAT_WELCOME_PROMPTS, ChatEmptyState } from '@/client/features/chat/ChatEmptyState' +import { CHAT_WELCOME_PROMPTS, ChatEmptyState } from '@/client/features/chat/messages/ChatEmptyState' import { renderMoiContext } from '@/lib/moi-context' function renderWelcome(disabled = false): string { diff --git a/client/features/chat/group-turns.test.ts b/client/features/chat/messages/group-turns.test.ts similarity index 99% rename from client/features/chat/group-turns.test.ts rename to client/features/chat/messages/group-turns.test.ts index 08dcc202..47387e18 100644 --- a/client/features/chat/group-turns.test.ts +++ b/client/features/chat/messages/group-turns.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { appendPreviewTurn, groupTurns } from '@/client/features/chat/group-turns' +import { appendPreviewTurn, groupTurns } from '@/client/features/chat/messages/group-turns' import type { Part, Turn } from '@/lib/types' const userTurn = (id: string, timestamp?: string): Turn => ({ diff --git a/client/features/chat/group-turns.ts b/client/features/chat/messages/group-turns.ts similarity index 100% rename from client/features/chat/group-turns.ts rename to client/features/chat/messages/group-turns.ts diff --git a/client/features/chat/interleave-notices.test.ts b/client/features/chat/messages/interleave-notices.test.ts similarity index 99% rename from client/features/chat/interleave-notices.test.ts rename to client/features/chat/messages/interleave-notices.test.ts index 3d910ff6..723433e4 100644 --- a/client/features/chat/interleave-notices.test.ts +++ b/client/features/chat/messages/interleave-notices.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/interleave-notices' +import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/messages/interleave-notices' import type { SystemNotice, Turn } from '@/lib/types' function turn(id: string, timestamp?: string): Turn { diff --git a/client/features/chat/interleave-notices.ts b/client/features/chat/messages/interleave-notices.ts similarity index 100% rename from client/features/chat/interleave-notices.ts rename to client/features/chat/messages/interleave-notices.ts diff --git a/client/features/chat/preview-turn.test.ts b/client/features/chat/messages/preview-turn.test.ts similarity index 98% rename from client/features/chat/preview-turn.test.ts rename to client/features/chat/messages/preview-turn.test.ts index fc34036f..3abb90d3 100644 --- a/client/features/chat/preview-turn.test.ts +++ b/client/features/chat/messages/preview-turn.test.ts @@ -2,12 +2,12 @@ // root preview into the current assistant run, whether it is thinking or text. import { describe, expect, test } from 'bun:test' -import { groupTurns } from '@/client/features/chat/group-turns' +import { groupTurns } from '@/client/features/chat/messages/group-turns' import { LIVE_PREVIEW_TURN_ID, buildPreviewTurn, previewBlocksToParts -} from '@/client/features/chat/preview-turn' +} from '@/client/features/chat/messages/preview-turn' import { type LivePreview, liveStore, selectPreviews } from '@/client/features/chat/chat-store' import type { Part, PreviewBlock, Turn, ToolCall } from '@/lib/types' diff --git a/client/features/chat/preview-turn.ts b/client/features/chat/messages/preview-turn.ts similarity index 100% rename from client/features/chat/preview-turn.ts rename to client/features/chat/messages/preview-turn.ts diff --git a/client/features/chat/useStickToBottom.ts b/client/features/chat/messages/useStickToBottom.ts similarity index 100% rename from client/features/chat/useStickToBottom.ts rename to client/features/chat/messages/useStickToBottom.ts diff --git a/client/features/chat/ChatSelector.test.ts b/client/features/chat/sessions/ChatSelector.test.ts similarity index 98% rename from client/features/chat/ChatSelector.test.ts rename to client/features/chat/sessions/ChatSelector.test.ts index f35a4d52..486d9ce7 100644 --- a/client/features/chat/ChatSelector.test.ts +++ b/client/features/chat/sessions/ChatSelector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { SessionInfo } from '@/lib/types' -import { removeArchivedSession } from './api' +import { removeArchivedSession } from '../api' import { groupSessionsByDate, sessionBadge } from './ChatSelector' function session(sessionId: string, summary: string, lastModified: string): SessionInfo { diff --git a/client/features/chat/ChatSelector.tsx b/client/features/chat/sessions/ChatSelector.tsx similarity index 99% rename from client/features/chat/ChatSelector.tsx rename to client/features/chat/sessions/ChatSelector.tsx index 4347fd2b..70c18cd5 100644 --- a/client/features/chat/ChatSelector.tsx +++ b/client/features/chat/sessions/ChatSelector.tsx @@ -1,10 +1,10 @@ import { useRef, useState } from 'react' import { IconArchive, IconChevronDown, IconEdit } from '@tabler/icons-react' -import { useArchiveWorkspaceSession, useWorkspaceSessions } from './api' +import { useArchiveWorkspaceSession, useWorkspaceSessions } from '../api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' -import { useSelectedSession } from '@/client/features/chat/useSelectedSession' +import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { cn } from '@/client/lib/cn' import { hasRunningBackgroundSession, diff --git a/client/features/chat/session-view.test.ts b/client/features/chat/sessions/session-view.test.ts similarity index 99% rename from client/features/chat/session-view.test.ts rename to client/features/chat/sessions/session-view.test.ts index ae200c8f..efb9cc47 100644 --- a/client/features/chat/session-view.test.ts +++ b/client/features/chat/sessions/session-view.test.ts @@ -5,7 +5,7 @@ import { workspaceKeys } from '@/client/api/workspace-keys' import { applyEvents, emptyViewState } from '@/lib/format' import type { StreamEvent, ViewState } from '@/lib/types' -import { reduceChatFrame } from './chat-frames' +import { reduceChatFrame } from '../connection/chat-frames' import { sessionViewOptions } from './session-view' const workspaceId = 'workspace' diff --git a/client/features/chat/session-view.ts b/client/features/chat/sessions/session-view.ts similarity index 100% rename from client/features/chat/session-view.ts rename to client/features/chat/sessions/session-view.ts diff --git a/client/features/chat/useSelectedSession.test.ts b/client/features/chat/sessions/useSelectedSession.test.ts similarity index 98% rename from client/features/chat/useSelectedSession.test.ts rename to client/features/chat/sessions/useSelectedSession.test.ts index 55a57c8b..fce3dd47 100644 --- a/client/features/chat/useSelectedSession.test.ts +++ b/client/features/chat/sessions/useSelectedSession.test.ts @@ -6,7 +6,7 @@ import { applySelectedSessionEvent, optimisticallySetSelectedSession, settleSelectedSessionSave -} from '@/client/features/chat/useSelectedSession' +} from '@/client/features/chat/sessions/useSelectedSession' import type { SelectedSessionState } from '@/lib/types' const WORKSPACE_ID = 'workspace-1' diff --git a/client/features/chat/useSelectedSession.ts b/client/features/chat/sessions/useSelectedSession.ts similarity index 100% rename from client/features/chat/useSelectedSession.ts rename to client/features/chat/sessions/useSelectedSession.ts diff --git a/client/features/chat/tool-group/SubagentCard.tsx b/client/features/chat/tool-group/SubagentCard.tsx index 25d4a225..eac12a06 100644 --- a/client/features/chat/tool-group/SubagentCard.tsx +++ b/client/features/chat/tool-group/SubagentCard.tsx @@ -10,7 +10,7 @@ import { type ReactNode, useEffect, useState } from 'react' import { IconPlayBasketball, IconPlayFootball, IconPlayVolleyball } from '@tabler/icons-react' -import { ShimmerText } from '@/client/features/chat/ShimmerText' +import { ShimmerText } from '@/client/features/chat/messages/ShimmerText' import { cn } from '@/client/lib/cn' import type { Part, ToolCall } from '@/lib/types' diff --git a/client/features/chat/tool-group/ToolCallGroup.tsx b/client/features/chat/tool-group/ToolCallGroup.tsx index c5e2436c..3ea0c28a 100644 --- a/client/features/chat/tool-group/ToolCallGroup.tsx +++ b/client/features/chat/tool-group/ToolCallGroup.tsx @@ -15,7 +15,7 @@ import { type ReactNode, useState } from 'react' import { IconLoader2, IconPackage } from '@tabler/icons-react' import { cn } from '@/client/lib/cn' -import { PlainMarkdownText } from '@/client/features/chat/MarkdownContent' +import { PlainMarkdownText } from '@/client/features/chat/messages/MarkdownContent' import { formatMcpServerName, getMcpIcon } from '@/client/features/connectors/mcp-icons' import type { Part, ToolCall } from '@/lib/types' diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 366b94aa..cf8aa33b 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -5,14 +5,14 @@ import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' import { useSessionConfig, useSessionView, useWorkspaceSessions } from '@/client/features/chat/api' import { useWorkspaceAgent } from '@/client/features/workspace/api' -import { useSelectedSession } from '@/client/features/chat/useSelectedSession' +import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { type WorkspaceTabAddress, useMoiUserMessageContext } from '@/client/features/workspace/moi-context' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' -import { sendMessage } from '@/client/features/chat/chat-connection' +import { sendMessage } from '@/client/features/chat/connection/chat-connection' import { type ChatSendOptions, attachmentPartsForOptimisticTurn, @@ -23,7 +23,7 @@ import { startOptimisticTurn, withAttachmentDirectives } from '@/client/features/chat/chat-send' -import { buildPreviewTurn } from '@/client/features/chat/preview-turn' +import { buildPreviewTurn } from '@/client/features/chat/messages/preview-turn' import { isRunningActivity, liveStore, diff --git a/client/features/dev/ChatStatesPage.tsx b/client/features/dev/ChatStatesPage.tsx index dd4fbb3b..22fb5ad1 100644 --- a/client/features/dev/ChatStatesPage.tsx +++ b/client/features/dev/ChatStatesPage.tsx @@ -24,12 +24,12 @@ import { import { Switch } from '@/client/components/ui/switch' import { ChatNoticeRow } from '@/client/features/chat/ChatPanel' import { ErrorBanner } from '@/client/features/chat/composer/banners/ErrorBanner' -import { ChatSessionItem } from '@/client/features/chat/ChatSelector' -import { TurnView } from '@/client/features/chat/TurnView' +import { ChatSessionItem } from '@/client/features/chat/sessions/ChatSelector' +import { TurnView } from '@/client/features/chat/messages/TurnView' import { isSessionRunning, liveStore } from '@/client/features/chat/chat-store' -import { groupTurns } from '@/client/features/chat/group-turns' -import { interleaveNotices } from '@/client/features/chat/interleave-notices' -import { buildPreviewTurn } from '@/client/features/chat/preview-turn' +import { groupTurns } from '@/client/features/chat/messages/group-turns' +import { interleaveNotices } from '@/client/features/chat/messages/interleave-notices' +import { buildPreviewTurn } from '@/client/features/chat/messages/preview-turn' import { WorkspaceLayoutContext, type WorkspaceLayoutContextValue diff --git a/client/features/drawings/useChatAnnotation.ts b/client/features/drawings/useChatAnnotation.ts index b99a8265..7e2befe2 100644 --- a/client/features/drawings/useChatAnnotation.ts +++ b/client/features/drawings/useChatAnnotation.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { toast } from '@/client/components/ui/toast' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/attachment-staging' +import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/attachment-staging' import { liveStore } from '@/client/features/chat/chat-store' import type { ComposerAnnotationControls } from '@/client/features/chat/composer/ChatComposer' import { useLatestRef } from '@/client/lib/use-latest-ref' diff --git a/client/features/drawings/useViewBuilderSketch.ts b/client/features/drawings/useViewBuilderSketch.ts index be298e80..56aee006 100644 --- a/client/features/drawings/useViewBuilderSketch.ts +++ b/client/features/drawings/useViewBuilderSketch.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/attachment-staging' +import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/attachment-staging' import { attachmentKey, liveStore } from '@/client/features/chat/chat-store' import { useLatestRef } from '@/client/lib/use-latest-ref' import type { WorkspaceTabId } from '@/lib/types' diff --git a/client/features/views/useViewBuilderActions.ts b/client/features/views/useViewBuilderActions.ts index 81bd3a4d..ad4ff782 100644 --- a/client/features/views/useViewBuilderActions.ts +++ b/client/features/views/useViewBuilderActions.ts @@ -8,7 +8,7 @@ import { startOptimisticTurn } from '@/client/features/chat/chat-send' import { liveStore } from '@/client/features/chat/chat-store' -import { useSelectedSession } from '@/client/features/chat/useSelectedSession' +import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 1bf6e877..477ff6a9 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -15,7 +15,7 @@ import { DrawingLayer } from '@/client/features/drawings/DrawingLayer' import { DrawingToolbar } from '@/client/features/drawings/DrawingToolbar' import { useChatAnnotation } from '@/client/features/drawings/useChatAnnotation' import { ChatPanel } from '@/client/features/chat/ChatPanel' -import type { WelcomeDestination } from '@/client/features/chat/ChatEmptyState' +import type { WelcomeDestination } from '@/client/features/chat/messages/ChatEmptyState' import { ChatPopup } from '@/client/features/chat/ChatPopup' import { ThemePanel } from '@/client/features/workspace/ThemePanel' import { useAppletEvent } from '@/client/features/applets/applet-runtime' diff --git a/client/lib/connection.stream.test.ts b/client/lib/connection.stream.test.ts index 514640a0..5cbb1558 100644 --- a/client/lib/connection.stream.test.ts +++ b/client/lib/connection.stream.test.ts @@ -11,7 +11,7 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, test } from 'bun:test' import { workspaceKeys } from '@/client/api/workspace-keys' -import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/chat-connection' +import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/connection/chat-connection' import { liveStore } from '@/client/features/chat/chat-store' import { emptyViewState } from '@/lib/format' import type { PreviewBlock, Turn, ViewState } from '@/lib/types' diff --git a/client/main.tsx b/client/main.tsx index c3708117..b8ff41b6 100644 --- a/client/main.tsx +++ b/client/main.tsx @@ -6,7 +6,7 @@ import { Router, useRoute } from 'wouter' import { Toaster } from '@/client/components/ui/toast' import { TooltipProvider } from '@/client/components/ui/tooltip' import { installAppletErrorHook } from '@/client/features/applets/applet-log' -import { initConnection } from '@/client/features/chat/chat-connection' +import { initConnection } from '@/client/features/chat/connection/chat-connection' import { AppRouter } from './app/AppRouter' const queryClient = new QueryClient() From 9c7a9c99403aefb76e86f714247ae69ad6e39427 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 14:34:11 +0200 Subject: [PATCH 07/24] refactor: clarify chat folder ownership --- ...eAppletChatMessage.test.ts => applet-chat-intents.test.ts} | 2 +- .../chat/{useAppletChatMessage.ts => applet-chat-intents.ts} | 0 client/features/chat/composer/ChatComposer.tsx | 2 +- client/features/chat/composer/ModelPicker.tsx | 2 +- .../{attachment-staging.test.ts => draft-attachments.test.ts} | 2 +- .../{attachment-staging.ts => draft-attachments.ts} | 0 client/features/chat/messages/TurnView.tsx | 4 ++-- client/features/chat/{ => messages}/tool-group/CodeBlock.tsx | 0 client/features/chat/{ => messages}/tool-group/Collapse.tsx | 0 .../chat/{ => messages}/tool-group/ReadImagePreview.tsx | 0 .../features/chat/{ => messages}/tool-group/SubagentCard.tsx | 0 .../features/chat/{ => messages}/tool-group/TimelineRow.tsx | 0 .../features/chat/{ => messages}/tool-group/ToolCallGroup.tsx | 0 client/features/chat/{ => messages}/tool-group/ToolOutput.tsx | 0 client/features/chat/{ => messages}/tool-group/detect.ts | 0 client/features/chat/{ => messages}/tool-group/fixtures.ts | 0 client/features/chat/{ => messages}/tool-group/format.test.ts | 0 .../features/chat/{ => messages}/tool-group/format/claude.ts | 0 .../features/chat/{ => messages}/tool-group/format/codex.ts | 0 .../features/chat/{ => messages}/tool-group/format/hermes.ts | 0 .../features/chat/{ => messages}/tool-group/format/index.ts | 0 client/features/chat/{ => messages}/tool-group/format/mcp.ts | 0 .../chat/{ => messages}/tool-group/format/openclaw.ts | 0 .../features/chat/{ => messages}/tool-group/format/shared.ts | 0 .../chat/{ => messages}/tool-group/format/shell.test.ts | 0 .../features/chat/{ => messages}/tool-group/format/shell.ts | 0 client/features/chat/{ => messages}/tool-group/index.ts | 0 .../chat/{ => messages}/tool-group/openclaw-tools.test.ts | 0 client/features/chat/sessions/ChatSelector.test.ts | 2 +- client/features/chat/sessions/ChatSelector.tsx | 2 +- client/features/chat/{ => sessions}/api.ts | 0 client/features/chat/useChat.ts | 2 +- client/features/dev/ToolCallsPage.tsx | 2 +- client/features/drawings/useChatAnnotation.ts | 2 +- client/features/drawings/useViewBuilderSketch.ts | 2 +- client/features/workspace/WorkspaceScreen.tsx | 2 +- server/test/preview.test.ts | 2 +- 37 files changed, 14 insertions(+), 14 deletions(-) rename client/features/chat/{useAppletChatMessage.test.ts => applet-chat-intents.test.ts} (98%) rename client/features/chat/{useAppletChatMessage.ts => applet-chat-intents.ts} (100%) rename client/features/chat/composer/attachments/{attachment-staging.test.ts => draft-attachments.test.ts} (98%) rename client/features/chat/composer/attachments/{attachment-staging.ts => draft-attachments.ts} (100%) rename client/features/chat/{ => messages}/tool-group/CodeBlock.tsx (100%) rename client/features/chat/{ => messages}/tool-group/Collapse.tsx (100%) rename client/features/chat/{ => messages}/tool-group/ReadImagePreview.tsx (100%) rename client/features/chat/{ => messages}/tool-group/SubagentCard.tsx (100%) rename client/features/chat/{ => messages}/tool-group/TimelineRow.tsx (100%) rename client/features/chat/{ => messages}/tool-group/ToolCallGroup.tsx (100%) rename client/features/chat/{ => messages}/tool-group/ToolOutput.tsx (100%) rename client/features/chat/{ => messages}/tool-group/detect.ts (100%) rename client/features/chat/{ => messages}/tool-group/fixtures.ts (100%) rename client/features/chat/{ => messages}/tool-group/format.test.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/claude.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/codex.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/hermes.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/index.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/mcp.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/openclaw.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/shared.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/shell.test.ts (100%) rename client/features/chat/{ => messages}/tool-group/format/shell.ts (100%) rename client/features/chat/{ => messages}/tool-group/index.ts (100%) rename client/features/chat/{ => messages}/tool-group/openclaw-tools.test.ts (100%) rename client/features/chat/{ => sessions}/api.ts (100%) diff --git a/client/features/chat/useAppletChatMessage.test.ts b/client/features/chat/applet-chat-intents.test.ts similarity index 98% rename from client/features/chat/useAppletChatMessage.test.ts rename to client/features/chat/applet-chat-intents.test.ts index ac51011a..a61fcca8 100644 --- a/client/features/chat/useAppletChatMessage.test.ts +++ b/client/features/chat/applet-chat-intents.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import { canSubmitComposerAction } from '@/client/components/shared/Composer' -import { appletSendBlockedReason } from '@/client/features/chat/useAppletChatMessage' +import { appletSendBlockedReason } from '@/client/features/chat/applet-chat-intents' import type { AgentAvailability } from '@/client/lib/agent-availability' // An applet message must not start a run the composer's own send button would diff --git a/client/features/chat/useAppletChatMessage.ts b/client/features/chat/applet-chat-intents.ts similarity index 100% rename from client/features/chat/useAppletChatMessage.ts rename to client/features/chat/applet-chat-intents.ts diff --git a/client/features/chat/composer/ChatComposer.tsx b/client/features/chat/composer/ChatComposer.tsx index ec1fd9a8..b51db3b4 100644 --- a/client/features/chat/composer/ChatComposer.tsx +++ b/client/features/chat/composer/ChatComposer.tsx @@ -19,7 +19,7 @@ import { import { Button } from '@/client/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/client/components/ui/hover-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' -import { stageComposerFiles } from '@/client/features/chat/composer/attachments/attachment-staging' +import { stageComposerFiles } from '@/client/features/chat/composer/attachments/draft-attachments' import { cn } from '@/client/lib/cn' import type { AgentAvailability } from '@/client/lib/agent-availability' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' diff --git a/client/features/chat/composer/ModelPicker.tsx b/client/features/chat/composer/ModelPicker.tsx index d856cda2..a37a0dc1 100644 --- a/client/features/chat/composer/ModelPicker.tsx +++ b/client/features/chat/composer/ModelPicker.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'motion/react' import { IconBolt, IconBoltFilled } from '@tabler/icons-react' -import { useSaveSessionConfig, useSessionConfig } from '../api' +import { useSaveSessionConfig, useSessionConfig } from '../sessions/api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { groupModels, diff --git a/client/features/chat/composer/attachments/attachment-staging.test.ts b/client/features/chat/composer/attachments/draft-attachments.test.ts similarity index 98% rename from client/features/chat/composer/attachments/attachment-staging.test.ts rename to client/features/chat/composer/attachments/draft-attachments.test.ts index 5b5c75c3..e1386d03 100644 --- a/client/features/chat/composer/attachments/attachment-staging.test.ts +++ b/client/features/chat/composer/attachments/draft-attachments.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' -import { stageDrawing, stageDrawingDraft } from './attachment-staging' +import { stageDrawing, stageDrawingDraft } from './draft-attachments' import { attachmentKey, liveStore } from '../../chat-store' const workspaceId = 'workspace-1' diff --git a/client/features/chat/composer/attachments/attachment-staging.ts b/client/features/chat/composer/attachments/draft-attachments.ts similarity index 100% rename from client/features/chat/composer/attachments/attachment-staging.ts rename to client/features/chat/composer/attachments/draft-attachments.ts diff --git a/client/features/chat/messages/TurnView.tsx b/client/features/chat/messages/TurnView.tsx index c34e6727..5d7d26b5 100644 --- a/client/features/chat/messages/TurnView.tsx +++ b/client/features/chat/messages/TurnView.tsx @@ -10,8 +10,8 @@ import { CollapsibleTrigger } from '@/client/components/ui/collapsible' import { MarkdownContent } from '@/client/features/chat/messages/MarkdownContent' -import { ToolCallGroup } from '@/client/features/chat/tool-group/ToolCallGroup' -import { formatDuration } from '@/client/features/chat/tool-group/format' +import { ToolCallGroup } from '@/client/features/chat/messages/tool-group/ToolCallGroup' +import { formatDuration } from '@/client/features/chat/messages/tool-group/format' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { cn } from '@/client/lib/cn' diff --git a/client/features/chat/tool-group/CodeBlock.tsx b/client/features/chat/messages/tool-group/CodeBlock.tsx similarity index 100% rename from client/features/chat/tool-group/CodeBlock.tsx rename to client/features/chat/messages/tool-group/CodeBlock.tsx diff --git a/client/features/chat/tool-group/Collapse.tsx b/client/features/chat/messages/tool-group/Collapse.tsx similarity index 100% rename from client/features/chat/tool-group/Collapse.tsx rename to client/features/chat/messages/tool-group/Collapse.tsx diff --git a/client/features/chat/tool-group/ReadImagePreview.tsx b/client/features/chat/messages/tool-group/ReadImagePreview.tsx similarity index 100% rename from client/features/chat/tool-group/ReadImagePreview.tsx rename to client/features/chat/messages/tool-group/ReadImagePreview.tsx diff --git a/client/features/chat/tool-group/SubagentCard.tsx b/client/features/chat/messages/tool-group/SubagentCard.tsx similarity index 100% rename from client/features/chat/tool-group/SubagentCard.tsx rename to client/features/chat/messages/tool-group/SubagentCard.tsx diff --git a/client/features/chat/tool-group/TimelineRow.tsx b/client/features/chat/messages/tool-group/TimelineRow.tsx similarity index 100% rename from client/features/chat/tool-group/TimelineRow.tsx rename to client/features/chat/messages/tool-group/TimelineRow.tsx diff --git a/client/features/chat/tool-group/ToolCallGroup.tsx b/client/features/chat/messages/tool-group/ToolCallGroup.tsx similarity index 100% rename from client/features/chat/tool-group/ToolCallGroup.tsx rename to client/features/chat/messages/tool-group/ToolCallGroup.tsx diff --git a/client/features/chat/tool-group/ToolOutput.tsx b/client/features/chat/messages/tool-group/ToolOutput.tsx similarity index 100% rename from client/features/chat/tool-group/ToolOutput.tsx rename to client/features/chat/messages/tool-group/ToolOutput.tsx diff --git a/client/features/chat/tool-group/detect.ts b/client/features/chat/messages/tool-group/detect.ts similarity index 100% rename from client/features/chat/tool-group/detect.ts rename to client/features/chat/messages/tool-group/detect.ts diff --git a/client/features/chat/tool-group/fixtures.ts b/client/features/chat/messages/tool-group/fixtures.ts similarity index 100% rename from client/features/chat/tool-group/fixtures.ts rename to client/features/chat/messages/tool-group/fixtures.ts diff --git a/client/features/chat/tool-group/format.test.ts b/client/features/chat/messages/tool-group/format.test.ts similarity index 100% rename from client/features/chat/tool-group/format.test.ts rename to client/features/chat/messages/tool-group/format.test.ts diff --git a/client/features/chat/tool-group/format/claude.ts b/client/features/chat/messages/tool-group/format/claude.ts similarity index 100% rename from client/features/chat/tool-group/format/claude.ts rename to client/features/chat/messages/tool-group/format/claude.ts diff --git a/client/features/chat/tool-group/format/codex.ts b/client/features/chat/messages/tool-group/format/codex.ts similarity index 100% rename from client/features/chat/tool-group/format/codex.ts rename to client/features/chat/messages/tool-group/format/codex.ts diff --git a/client/features/chat/tool-group/format/hermes.ts b/client/features/chat/messages/tool-group/format/hermes.ts similarity index 100% rename from client/features/chat/tool-group/format/hermes.ts rename to client/features/chat/messages/tool-group/format/hermes.ts diff --git a/client/features/chat/tool-group/format/index.ts b/client/features/chat/messages/tool-group/format/index.ts similarity index 100% rename from client/features/chat/tool-group/format/index.ts rename to client/features/chat/messages/tool-group/format/index.ts diff --git a/client/features/chat/tool-group/format/mcp.ts b/client/features/chat/messages/tool-group/format/mcp.ts similarity index 100% rename from client/features/chat/tool-group/format/mcp.ts rename to client/features/chat/messages/tool-group/format/mcp.ts diff --git a/client/features/chat/tool-group/format/openclaw.ts b/client/features/chat/messages/tool-group/format/openclaw.ts similarity index 100% rename from client/features/chat/tool-group/format/openclaw.ts rename to client/features/chat/messages/tool-group/format/openclaw.ts diff --git a/client/features/chat/tool-group/format/shared.ts b/client/features/chat/messages/tool-group/format/shared.ts similarity index 100% rename from client/features/chat/tool-group/format/shared.ts rename to client/features/chat/messages/tool-group/format/shared.ts diff --git a/client/features/chat/tool-group/format/shell.test.ts b/client/features/chat/messages/tool-group/format/shell.test.ts similarity index 100% rename from client/features/chat/tool-group/format/shell.test.ts rename to client/features/chat/messages/tool-group/format/shell.test.ts diff --git a/client/features/chat/tool-group/format/shell.ts b/client/features/chat/messages/tool-group/format/shell.ts similarity index 100% rename from client/features/chat/tool-group/format/shell.ts rename to client/features/chat/messages/tool-group/format/shell.ts diff --git a/client/features/chat/tool-group/index.ts b/client/features/chat/messages/tool-group/index.ts similarity index 100% rename from client/features/chat/tool-group/index.ts rename to client/features/chat/messages/tool-group/index.ts diff --git a/client/features/chat/tool-group/openclaw-tools.test.ts b/client/features/chat/messages/tool-group/openclaw-tools.test.ts similarity index 100% rename from client/features/chat/tool-group/openclaw-tools.test.ts rename to client/features/chat/messages/tool-group/openclaw-tools.test.ts diff --git a/client/features/chat/sessions/ChatSelector.test.ts b/client/features/chat/sessions/ChatSelector.test.ts index 486d9ce7..f35a4d52 100644 --- a/client/features/chat/sessions/ChatSelector.test.ts +++ b/client/features/chat/sessions/ChatSelector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { SessionInfo } from '@/lib/types' -import { removeArchivedSession } from '../api' +import { removeArchivedSession } from './api' import { groupSessionsByDate, sessionBadge } from './ChatSelector' function session(sessionId: string, summary: string, lastModified: string): SessionInfo { diff --git a/client/features/chat/sessions/ChatSelector.tsx b/client/features/chat/sessions/ChatSelector.tsx index 70c18cd5..5476d341 100644 --- a/client/features/chat/sessions/ChatSelector.tsx +++ b/client/features/chat/sessions/ChatSelector.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from 'react' import { IconArchive, IconChevronDown, IconEdit } from '@tabler/icons-react' -import { useArchiveWorkspaceSession, useWorkspaceSessions } from '../api' +import { useArchiveWorkspaceSession, useWorkspaceSessions } from './api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' diff --git a/client/features/chat/api.ts b/client/features/chat/sessions/api.ts similarity index 100% rename from client/features/chat/api.ts rename to client/features/chat/sessions/api.ts diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index cf8aa33b..3a0ace68 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' -import { useSessionConfig, useSessionView, useWorkspaceSessions } from '@/client/features/chat/api' +import { useSessionConfig, useSessionView, useWorkspaceSessions } from '@/client/features/chat/sessions/api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { diff --git a/client/features/dev/ToolCallsPage.tsx b/client/features/dev/ToolCallsPage.tsx index 07d04cc4..9bc63135 100644 --- a/client/features/dev/ToolCallsPage.tsx +++ b/client/features/dev/ToolCallsPage.tsx @@ -9,7 +9,7 @@ import { subagentDoneTrace, subagentTrace, ToolCallGroup -} from '@/client/features/chat/tool-group' +} from '@/client/features/chat/messages/tool-group' const VARIANTS = [ { value: 'single', label: 'Single' }, diff --git a/client/features/drawings/useChatAnnotation.ts b/client/features/drawings/useChatAnnotation.ts index 7e2befe2..b682f944 100644 --- a/client/features/drawings/useChatAnnotation.ts +++ b/client/features/drawings/useChatAnnotation.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { toast } from '@/client/components/ui/toast' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/attachment-staging' +import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/draft-attachments' import { liveStore } from '@/client/features/chat/chat-store' import type { ComposerAnnotationControls } from '@/client/features/chat/composer/ChatComposer' import { useLatestRef } from '@/client/lib/use-latest-ref' diff --git a/client/features/drawings/useViewBuilderSketch.ts b/client/features/drawings/useViewBuilderSketch.ts index 56aee006..f188c84c 100644 --- a/client/features/drawings/useViewBuilderSketch.ts +++ b/client/features/drawings/useViewBuilderSketch.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/attachment-staging' +import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/draft-attachments' import { attachmentKey, liveStore } from '@/client/features/chat/chat-store' import { useLatestRef } from '@/client/lib/use-latest-ref' import type { WorkspaceTabId } from '@/lib/types' diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 477ff6a9..4fbbeb04 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -24,7 +24,7 @@ import { PanelHeader } from '@/client/components/shared/PanelHeader' import { WorkspaceIcon } from '@/client/components/shared/WorkspaceIcon' import { Button } from '@/client/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' -import { useAppletChatMessage } from '@/client/features/chat/useAppletChatMessage' +import { useAppletChatMessage } from '@/client/features/chat/applet-chat-intents' import { useChat } from '@/client/features/chat/useChat' import { ViewBuilder, type ViewBuilderHandle } from '@/client/features/views/ViewBuilder' import { ViewManager } from '@/client/features/views/ViewManager' diff --git a/server/test/preview.test.ts b/server/test/preview.test.ts index 370377b1..e3afecf8 100644 --- a/server/test/preview.test.ts +++ b/server/test/preview.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import sharp from 'sharp' -import { readImageRelPath } from '@/client/features/chat/tool-group/ReadImagePreview' +import { readImageRelPath } from '@/client/features/chat/messages/tool-group/ReadImagePreview' import type { ToolCall } from '@/lib/types' import { serveWorkspaceImagePreview } from '../preview' From d6f899317f39d10c3660c909cce82b53d2adeb03 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 15:06:39 +0200 Subject: [PATCH 08/24] Update chat formatting paths in harness documentation --- server/harness/README.md | 2 +- server/harness/claude-code/NOTES.md | 2 +- server/harness/hermes/NOTES.md | 2 +- server/harness/openclaw/NOTES.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/harness/README.md b/server/harness/README.md index ed841eca..c3dc31e0 100644 --- a/server/harness/README.md +++ b/server/harness/README.md @@ -22,7 +22,7 @@ Message types flow through four layers; each has one home: │ REST payloads (SessionInfo, Models, …) re-exports it) ▼ 4. client React Query cache of StreamEvents + client/features/chat/* - ephemeral preview store → ViewState → (tool-group/format.ts holds the + ephemeral preview store → ViewState → (messages/tool-group/format/ holds the TurnView rendering per-provider labels/briefs) ``` diff --git a/server/harness/claude-code/NOTES.md b/server/harness/claude-code/NOTES.md index eb63c893..58518ddf 100644 --- a/server/harness/claude-code/NOTES.md +++ b/server/harness/claude-code/NOTES.md @@ -10,7 +10,7 @@ spec for the CC adapter. > their wire examples are not an exhaustive list of today's SDK messages, and > the migration proposed in §14 has shipped. Current display types live in > `lib/format.ts`, mapping in `adapter.ts`, and rendering in -> `client/features/chat/TurnView.tsx`. Use the installed `sdk.d.ts` for current +> `client/features/chat/messages/TurnView.tsx`. Use the installed `sdk.d.ts` for current > wire types. Evidence drawn from: diff --git a/server/harness/hermes/NOTES.md b/server/harness/hermes/NOTES.md index cf91d736..2033d36f 100644 --- a/server/harness/hermes/NOTES.md +++ b/server/harness/hermes/NOTES.md @@ -582,7 +582,7 @@ precedent: `lib/types.ts` (`WorkspaceType` union), `lib/workspace-types.ts` (ordering), `lib/format.ts` (`provider` union), `server/harness/registry.ts`, `server/harness/executable.ts` (PATH lookup), `server/api.ts` (`CREATABLE_TYPES`), and a handful of client files that branch on provider -(`client/features/chat/tool-group/format.ts`, +(`client/features/chat/messages/tool-group/format/index.ts`, `client/features/home/workspace-setup/WorkspaceAgentStep.tsx`, `client/features/dev/HarnessDebugPage.tsx`). diff --git a/server/harness/openclaw/NOTES.md b/server/harness/openclaw/NOTES.md index 19c58ad7..138b8089 100644 --- a/server/harness/openclaw/NOTES.md +++ b/server/harness/openclaw/NOTES.md @@ -570,7 +570,7 @@ So there is no Read card on a codex-backed run: the model reads by shelling out records. Nothing is being dropped. The client's tool-card labels and briefs live in -`client/features/chat/tool-group/format.ts`; the OpenClaw path falls back to the +`client/features/chat/messages/tool-group/format/index.ts`; the OpenClaw path falls back to the Claude Code vocabulary for the `claude-cli` case. **`agent`/`item` frames are not a second source of tool cards.** They carry From e592b0ebfbc6d01fad5ba7528c04d334a583d38e Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 15:29:03 +0200 Subject: [PATCH 09/24] Improve workspace UI and interaction flows --- .../features/applets/applet-runtime.test.ts | 65 ++++--- client/features/applets/applet-runtime.ts | 38 +++- client/features/chat/ChatPanel.tsx | 13 +- client/features/chat/ChatPopup.tsx | 7 +- client/features/chat/applet-chat-intents.ts | 27 +-- client/features/chat/chat-activity.test.ts | 5 +- client/features/chat/chat-send.test.ts | 18 +- client/features/chat/chat-send.ts | 19 +- client/features/chat/chat-store.ts | 67 +++---- .../features/chat/composer/ChatComposer.tsx | 163 ++++-------------- .../attachments/context-attachments.test.ts | 90 ++++++++++ .../attachments/draft-attachments.test.ts | 31 ++-- .../composer/attachments/draft-attachments.ts | 31 +++- .../chat/composer/attachments/types.ts | 33 ++++ .../messages/ExploreWorkspaceState.test.tsx | 5 +- client/features/chat/messages/TurnView.tsx | 9 +- .../chat/messages/WelcomeState.test.tsx | 5 +- .../chat/messages/interleave-notices.test.ts | 5 +- client/features/chat/useChat.ts | 10 +- client/features/dev/chat-states-fixtures.ts | 6 + client/features/drawings/useChatAnnotation.ts | 5 +- .../features/drawings/useViewBuilderSketch.ts | 6 +- .../features/views/useViewBuilderActions.ts | 5 +- client/features/workspace/WorkspaceScreen.tsx | 6 +- client/lib/connection.stream.test.ts | 5 +- lib/chat-title.ts | 3 +- lib/message-attachments.test.ts | 35 ++++ lib/message-attachments.ts | 31 ++++ lib/moi-attachments.test.ts | 92 ++++++++++ lib/moi-attachments.ts | 120 +++++++++++++ server/api-view-builders.test.ts | 53 +++++- server/applets/build-applet.ts | 10 +- server/harness/acp/adapter.ts | 11 +- server/harness/acp/session.test.ts | 18 ++ server/harness/acp/session.ts | 21 ++- server/harness/claude-code/adapter.ts | 5 +- server/harness/claude-code/session.test.ts | 18 ++ server/harness/claude-code/session.ts | 34 ++-- server/harness/claude-code/sessions.ts | 10 +- server/harness/codex/adapter.ts | 11 +- server/harness/codex/session.test.ts | 37 +++- server/harness/codex/session.ts | 32 ++-- server/harness/openclaw/adapter.ts | 20 ++- server/harness/openclaw/live-frames.test.ts | 34 +++- server/harness/openclaw/session.ts | 35 +++- server/test/__fixtures__/with-chatcontext.tsx | 5 + .../__fixtures__/with-sendchatmessage.tsx | 2 +- server/test/build-applet.test.ts | 8 +- server/test/context-attachment-replay.test.ts | 53 ++++++ server/test/skills-template.test.ts | 5 + 50 files changed, 1063 insertions(+), 314 deletions(-) create mode 100644 client/features/chat/composer/attachments/context-attachments.test.ts create mode 100644 client/features/chat/composer/attachments/types.ts create mode 100644 lib/message-attachments.test.ts create mode 100644 lib/message-attachments.ts create mode 100644 lib/moi-attachments.test.ts create mode 100644 lib/moi-attachments.ts create mode 100644 server/test/__fixtures__/with-chatcontext.tsx create mode 100644 server/test/context-attachment-replay.test.ts diff --git a/client/features/applets/applet-runtime.test.ts b/client/features/applets/applet-runtime.test.ts index 28754e43..367b2c0e 100644 --- a/client/features/applets/applet-runtime.test.ts +++ b/client/features/applets/applet-runtime.test.ts @@ -100,23 +100,46 @@ describe('sendChatMessage validation', () => { const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(WIDGET) - bridge.sendChatMessage(' Chase order A-1042 ', { order: 'A-1042' }) + bridge.sendChatMessage({ message: ' Chase order A-1042 ', context: { order: 'A-1042' } }) expect(calls).toEqual([ { message: 'Chase order A-1042', source: 'widget:clock', context: { order: 'A-1042' } } ]) }) + test('normalizes legacy calls through the same validation and rate limiter', () => { + const ws = `ws-${crypto.randomUUID()}` + const { calls } = subscribeChat(ws) + const { bridge } = appletRuntime(ws).connect(WIDGET) + + bridge.sendChatMessage(' Legacy ', { order: '1042' }) + bridge.sendChatMessage({ message: 'Legacy', context: { order: '1042' } }) + bridge.sendChatMessage('Message only') + bridge.sendChatMessage('Bad context', []) + bridge.sendChatMessage('x'.repeat(1001)) + bridge.sendChatMessage({ message: 'Object', context: { own: true } }, { ignored: true }) + + expect(calls).toEqual([ + { message: 'Legacy', source: 'widget:clock', context: { order: '1042' } }, + { message: 'Message only', source: 'widget:clock', context: undefined }, + { message: 'Bad context', source: 'widget:clock', context: undefined }, + { message: 'Object', source: 'widget:clock', context: { own: true } } + ]) + }) + test('drops a message that is empty, blank, or not a string', () => { const ws = `ws-${crypto.randomUUID()}` const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(VIEW) - bridge.sendChatMessage('') - bridge.sendChatMessage(' ') - bridge.sendChatMessage(42) + bridge.sendChatMessage({ message: '' }) + bridge.sendChatMessage({ message: ' ' }) + bridge.sendChatMessage({ message: 42 }) + bridge.sendChatMessage({ message: null }) bridge.sendChatMessage(null) - bridge.sendChatMessage({ toString: () => 'Do a thing' }) + bridge.sendChatMessage(' ') + bridge.sendChatMessage({ context: {} }) + bridge.sendChatMessage({ message: { toString: () => 'Do a thing' } }) expect(calls).toEqual([]) }) @@ -126,7 +149,7 @@ describe('sendChatMessage validation', () => { const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(VIEW) - bridge.sendChatMessage('x'.repeat(1001)) + bridge.sendChatMessage({ message: 'x'.repeat(1001) }) expect(calls).toEqual([]) }) @@ -139,9 +162,9 @@ describe('sendChatMessage validation', () => { const cyclic: Record = {} cyclic.self = cyclic - bridge.sendChatMessage('array context', ['not', 'a', 'record']) - bridge.sendChatMessage('cyclic context', cyclic) - bridge.sendChatMessage('huge context', { blob: 'x'.repeat(2001) }) + bridge.sendChatMessage({ message: 'array context', context: ['not', 'a', 'record'] }) + bridge.sendChatMessage({ message: 'cyclic context', context: cyclic }) + bridge.sendChatMessage({ message: 'huge context', context: { blob: 'x'.repeat(2001) } }) // The message carries the user's intent, so a bad payload must not lose it. expect(calls.map(c => [c.message, c.context])).toEqual([ @@ -156,9 +179,9 @@ describe('sendChatMessage validation', () => { const { calls } = subscribeChat(ws) const { bridge, dispose } = appletRuntime(ws).connect(VIEW) - bridge.sendChatMessage('before') + bridge.sendChatMessage({ message: 'before' }) dispose() - bridge.sendChatMessage('after') + bridge.sendChatMessage({ message: 'after' }) expect(calls.map(c => c.message)).toEqual(['before']) }) @@ -172,9 +195,9 @@ describe('sendChatMessage rate limiting', () => { const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(WIDGET) - bridge.sendChatMessage('Sync now') - bridge.sendChatMessage('Sync now') - bridge.sendChatMessage('Sync now') + bridge.sendChatMessage({ message: 'Sync now' }) + bridge.sendChatMessage({ message: 'Sync now' }) + bridge.sendChatMessage({ message: 'Sync now' }) expect(calls.map(c => c.message)).toEqual(['Sync now']) }) @@ -185,10 +208,10 @@ describe('sendChatMessage rate limiting', () => { const widget = appletRuntime(ws).connect(WIDGET) const view = appletRuntime(ws).connect(VIEW) - widget.bridge.sendChatMessage('Sync now') + widget.bridge.sendChatMessage({ message: 'Sync now' }) // Same text, different applet — a real second message. - view.bridge.sendChatMessage('Sync now') - widget.bridge.sendChatMessage('Something else') + view.bridge.sendChatMessage({ message: 'Sync now' }) + widget.bridge.sendChatMessage({ message: 'Something else' }) expect(calls.map(c => [c.source, c.message])).toEqual([ ['widget:clock', 'Sync now'], @@ -202,7 +225,7 @@ describe('sendChatMessage rate limiting', () => { const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(WIDGET) - for (let i = 0; i < 25; i++) bridge.sendChatMessage(`message ${i}`) + for (let i = 0; i < 25; i++) bridge.sendChatMessage({ message: `message ${i}` }) expect(calls).toHaveLength(10) }) @@ -214,9 +237,11 @@ describe('sendChatMessage rate limiting', () => { const b = subscribeChat(wsB) for (let i = 0; i < 25; i++) { - appletRuntime(wsA).connect(WIDGET).bridge.sendChatMessage(`a ${i}`) + appletRuntime(wsA) + .connect(WIDGET) + .bridge.sendChatMessage({ message: `a ${i}` }) } - appletRuntime(wsB).connect(WIDGET).bridge.sendChatMessage('b 0') + appletRuntime(wsB).connect(WIDGET).bridge.sendChatMessage({ message: 'b 0' }) expect(a.calls).toHaveLength(10) expect(b.calls.map(c => c.message)).toEqual(['b 0']) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index dfb0137f..5927a440 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -14,6 +14,8 @@ // — no central handlers object assembled by the screen. Applet → host only; // if a host → applet direction is ever added (`moi.on(...)`), `dispose` must // also unbind those listeners or a disposed module leaks. +import { snapshotChatContext } from '@/lib/moi-attachments' +import type { ContextAttachment } from '@/lib/types' import { useEffect } from 'react' import { createNanoEvents } from 'nanoevents' @@ -41,6 +43,7 @@ export type AppletChatMessage = { // Events a workspace runtime emits — already validated, typed for host code. export type AppletEvents = { + addChatContext: (attachment: ContextAttachment) => void // Client-local replace-navigation to a workspace tab. `params` reach the // target view as its `params` prop via navigation state — JSON-plain only // (history state is structured-cloned). @@ -54,8 +57,9 @@ export type AppletEvents = { // cross the trust boundary from agent-authored code, and the runtime narrows // them before emitting. export type AppletBridge = { + addChatContext: (input: unknown) => void focusTab: (tab: unknown, params?: unknown) => void - sendChatMessage: (message: unknown, context?: unknown) => void + sendChatMessage: (input: unknown, context?: unknown) => void } // A message longer than this is a bug, not a chat message — it would land in @@ -97,14 +101,14 @@ function createRuntime(workspaceId: string) { if (verdict === 'cooldown') { drop( identity, - `sendChatMessage("${text}") was dropped: the same message was already sent less than ${CHAT_LIMITS.cooldownMs / 1000}s ago. Call it from an event handler, not during render.` + `sendChatMessage() for "${text}" was dropped: the same message was already sent less than ${CHAT_LIMITS.cooldownMs / 1000}s ago. Call it from an event handler, not during render.` ) return false } if (verdict === 'window') { drop( identity, - `sendChatMessage("${text}") was dropped: more than ${CHAT_LIMITS.maxPerWindow} messages in a minute from this workspace. Each one starts an agent run, so send only on a real user action.` + `sendChatMessage() for "${text}" was dropped: more than ${CHAT_LIMITS.maxPerWindow} messages in a minute from this workspace. Each one starts an agent run, so send only on a real user action.` ) return false } @@ -124,20 +128,36 @@ function createRuntime(workspaceId: string) { let alive = true const source = appletSource(identity) const bridge: AppletBridge = { + addChatContext(input) { + if (!alive) return + const snapshot = snapshotChatContext(input) + if (!snapshot) { + drop( + identity, + 'addChatContext() was dropped: provide a non-empty label (max 120 characters) and a plain JSON context object (max 5000 characters).' + ) + return + } + emitter.emit('addChatContext', { ...snapshot, source }) + }, focusTab(tab, params) { if (!alive) return if (!isWorkspaceTabId(tab)) return emitter.emit('focusTab', tab, isParamsRecord(params) ? params : undefined) }, - sendChatMessage(message, context) { + sendChatMessage(input, legacyContext) { if (!alive) return - if (typeof message !== 'string') return + // Older applets pass (message, context?). Normalize to the object form + // so both APIs share validation and rate limits. + if (typeof input === 'string') input = { message: input, context: legacyContext } + if (!isParamsRecord(input) || typeof input.message !== 'string') return + const { message, context } = input const text = message.trim() if (!text) return if (text.length > MAX_MESSAGE_CHARS) { drop( identity, - `sendChatMessage() was dropped: the message is ${text.length} characters (max ${MAX_MESSAGE_CHARS}). Put long content in the context argument, not the message.` + `sendChatMessage() was dropped: the message is ${text.length} characters (max ${MAX_MESSAGE_CHARS}). Put long content in the context field, not the message.` ) return } @@ -171,7 +191,7 @@ function narrowChatContext( drop: (identity: AppletIdentity, message: string) => void ): Record | undefined { if (!isParamsRecord(context)) { - drop(identity, 'sendChatMessage() ignored its context argument: it must be a plain object.') + drop(identity, 'sendChatMessage() ignored its context field: it must be a plain object.') return undefined } let json: string @@ -180,14 +200,14 @@ function narrowChatContext( } catch { drop( identity, - 'sendChatMessage() ignored its context argument: it is not JSON-serializable (cycles, functions, or BigInt).' + 'sendChatMessage() ignored its context field: it is not JSON-serializable (cycles, functions, or BigInt).' ) return undefined } if (json.length > MAX_APPLET_CONTEXT_CHARS) { drop( identity, - `sendChatMessage() ignored its context argument: ${json.length} characters serialized (max ${MAX_APPLET_CONTEXT_CHARS}). Send an id the agent can look up instead of the whole payload.` + `sendChatMessage() ignored its context field: ${json.length} characters serialized (max ${MAX_APPLET_CONTEXT_CHARS}). Send an id the agent can look up instead of the whole payload.` ) return undefined } diff --git a/client/features/chat/ChatPanel.tsx b/client/features/chat/ChatPanel.tsx index ee42ddcb..693f7fbc 100644 --- a/client/features/chat/ChatPanel.tsx +++ b/client/features/chat/ChatPanel.tsx @@ -6,7 +6,10 @@ import { canSubmitComposerAction, focusComposer } from '@/client/components/shar import { AgentBlobatar } from '@/client/components/shared/AgentBlobatar' import { useStickToBottom } from '@/client/features/chat/messages/useStickToBottom' import { appendPreviewTurn, groupTurns } from '@/client/features/chat/messages/group-turns' -import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/messages/interleave-notices' +import { + chatNoticeLabel, + interleaveNotices +} from '@/client/features/chat/messages/interleave-notices' import { attachmentKey, useLive } from '@/client/features/chat/chat-store' import type { ChatPromptBubble } from '@/client/features/chat/messages/ChatPromptBubbles' import type { ChatSendOptions } from '@/client/features/chat/chat-send' @@ -15,7 +18,11 @@ import type { AgentTheme, SystemNotice, Turn, ViewState } from '@/lib/types' import type { ComposerBanner } from './composer/banners/ComposerBanner' import { ChatComposer, type ComposerAnnotationControls } from './composer/ChatComposer' -import { ChatEmptyState, type WelcomeDestination, resolveChatEmptyState } from './messages/ChatEmptyState' +import { + ChatEmptyState, + type WelcomeDestination, + resolveChatEmptyState +} from './messages/ChatEmptyState' import { ChatSelector } from './sessions/ChatSelector' import { TurnView } from './messages/TurnView' import { Button } from '@/client/components/ui/button' @@ -98,7 +105,7 @@ export function ChatPanel({ ) const attachmentsUploading = useLive(state => (state.attachments[attachmentKey(workspaceId, effectiveSessionId)] ?? []).some( - attachment => attachment.status === 'uploading' + attachment => attachment.kind !== 'context' && attachment.status === 'uploading' ) ) const promptDisabled = !canSubmitComposerAction(true, attachmentsUploading, agentAvailability) diff --git a/client/features/chat/ChatPopup.tsx b/client/features/chat/ChatPopup.tsx index d787914a..f55efcc6 100644 --- a/client/features/chat/ChatPopup.tsx +++ b/client/features/chat/ChatPopup.tsx @@ -29,7 +29,12 @@ export function ChatPopup({ nextOpen, eventDetails ) => { - if (!nextOpen && eventDetails.reason === 'outside-press') { + // Applet actions move focus outside before their click handler runs. + // Keep chat open so adding context only refocuses the existing composer. + if ( + !nextOpen && + (eventDetails.reason === 'outside-press' || eventDetails.reason === 'focus-out') + ) { eventDetails.cancel() return } diff --git a/client/features/chat/applet-chat-intents.ts b/client/features/chat/applet-chat-intents.ts index d3cc99fa..d7ba50a1 100644 --- a/client/features/chat/applet-chat-intents.ts +++ b/client/features/chat/applet-chat-intents.ts @@ -1,15 +1,8 @@ -// Chat's half of the applet `sendChatMessage` API: turn a validated runtime -// event into a real send on the active chat. -// -// The runtime already did the untrusted work — narrowed the args, stamped the -// applet's `:` as `source`, and rate-limited repeats -// (applet-runtime.ts). What's left is chat policy: reveal the chat, then send -// the text as an ordinary user message with the applet attribution riding the -// `` envelope. -// -// Sending while a turn is running is fine and deliberate — the composer offers -// the same ("Queue a follow-up"), and the harness queues the message into the -// live session rather than rejecting it. +// Connect validated applet intents to the active chat: stage context in the +// draft or send a message immediately, then reveal and focus chat. +// The runtime owns argument validation, source attribution, and send rate limits; +// these hooks own draft staging and agent availability checks for immediate sends. +import { stageChatContext } from './composer/attachments/draft-attachments' import { reportAppletError } from '@/client/features/applets/applet-log' import { type AppletChatMessage, useAppletEvent } from '@/client/features/applets/applet-runtime' import type { ChatSendOptions } from '@/client/features/chat/chat-send' @@ -55,7 +48,7 @@ export function useAppletChatMessage({ // button did nothing, and this is the only place that says why. reportAppletError(workspaceId, { source: 'runtime', - message: `sendChatMessage("${event.message}") was dropped: ${blocked}.` + message: `sendChatMessage() for "${event.message}" was dropped: ${blocked}.` }) return } @@ -66,3 +59,11 @@ export function useAppletChatMessage({ send(message, { applet }) }) } + +export function useAppletChatContext(sessionId: string | null, revealChat: () => void): void { + const workspaceId = useWorkspaceId() + useAppletEvent(workspaceId, 'addChatContext', attachment => { + stageChatContext({ workspaceId, sessionId }, attachment) + revealChat() + }) +} diff --git a/client/features/chat/chat-activity.test.ts b/client/features/chat/chat-activity.test.ts index 394904f8..112be416 100644 --- a/client/features/chat/chat-activity.test.ts +++ b/client/features/chat/chat-activity.test.ts @@ -6,7 +6,10 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, test } from 'bun:test' import { appUiKeys } from '@/client/api/app-ui-keys' -import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/connection/chat-connection' +import { + __setQueryClientForTests, + handleFrame +} from '@/client/features/chat/connection/chat-connection' import { hasRunningActivity, hasRunningBackgroundSession, diff --git a/client/features/chat/chat-send.test.ts b/client/features/chat/chat-send.test.ts index 9d19391c..7469ec24 100644 --- a/client/features/chat/chat-send.test.ts +++ b/client/features/chat/chat-send.test.ts @@ -11,7 +11,11 @@ import { startOptimisticTurn, withAttachmentDirectives } from '@/client/features/chat/chat-send' -import { attachmentKey, type ChatAttachment, liveStore } from '@/client/features/chat/chat-store' +import { attachmentKey, liveStore } from '@/client/features/chat/chat-store' +import type { + ChatAttachment, + UploadedChatAttachment +} from '@/client/features/chat/composer/attachments/types' import type { SessionInfo, ViewState, WorkspaceAgent } from '@/lib/types' import { resolveSelectedModel } from './composer/model-order' @@ -228,7 +232,7 @@ describe('composer attachments', () => { name: 'report.pdf', mediaType: 'application/pdf', status: 'ready', - upload: { id: 'up-1', kind: 'file' } as ChatAttachment['upload'] + upload: { id: 'up-1', kind: 'file' } as UploadedChatAttachment['upload'] }, { kind: 'file', @@ -280,7 +284,7 @@ describe('composer attachments', () => { name: 'Reference.png', mediaType: 'image/png', status: 'ready', - upload: { id: 'up-image', kind: 'image' } as ChatAttachment['upload'] + upload: { id: 'up-image', kind: 'image' } as UploadedChatAttachment['upload'] }, { kind: 'drawing', @@ -290,7 +294,7 @@ describe('composer attachments', () => { mediaType: 'image/png', sourceTab: 'overview', status: 'ready', - upload: { id: 'up-annotation', kind: 'image' } as ChatAttachment['upload'] + upload: { id: 'up-annotation', kind: 'image' } as UploadedChatAttachment['upload'] }, { kind: 'drawing', @@ -300,7 +304,7 @@ describe('composer attachments', () => { mediaType: 'image/png', sourceTab: 'view:roadmap', status: 'ready', - upload: { id: 'up-annotation-2', kind: 'image' } as ChatAttachment['upload'] + upload: { id: 'up-annotation-2', kind: 'image' } as UploadedChatAttachment['upload'] } ] @@ -321,7 +325,7 @@ describe('composer attachments', () => { mediaType: 'image/png', sourceTab: 'overview', status: 'ready', - upload: { id: 'up-annotation', kind: 'image' } as ChatAttachment['upload'] + upload: { id: 'up-annotation', kind: 'image' } as UploadedChatAttachment['upload'] } const options = { applet: { source: 'widget:late-orders' } } expect(withAttachmentDirectives(options, [annotation])).toBe(options) @@ -336,7 +340,7 @@ describe('composer attachments', () => { mediaType: 'image/png', sourceTab: 'view-builder:draft-1', status: 'ready', - upload: { id: 'up-sketch', kind: 'image' } as ChatAttachment['upload'] + upload: { id: 'up-sketch', kind: 'image' } as UploadedChatAttachment['upload'] } expect(withAttachmentDirectives(undefined, [sketch])).toEqual({ diff --git a/client/features/chat/chat-send.ts b/client/features/chat/chat-send.ts index 86a17415..878bb7c2 100644 --- a/client/features/chat/chat-send.ts +++ b/client/features/chat/chat-send.ts @@ -1,7 +1,10 @@ +import type { MessageAttachment } from '@/lib/types' +import { contextAttachmentParts } from '@/lib/moi-attachments' import type { QueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' -import { attachmentKey, type ChatAttachment, liveStore } from '@/client/features/chat/chat-store' +import { attachmentKey, liveStore } from '@/client/features/chat/chat-store' +import type { ChatAttachment } from '@/client/features/chat/composer/attachments/types' import { resolveSelectedModel } from '@/client/features/chat/composer/model-order' import type { MoiUserMessageOptions } from '@/client/features/workspace/moi-context' import { STREAM_RESPONSES } from '@/client/lib/flags' @@ -30,11 +33,12 @@ export function attachmentsForSend( ): ChatAttachment[] { if (!ownsComposerAttachments(options)) return [] const pending = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] ?? [] - return pending.filter(a => a.status === 'ready' && a.upload) + return pending.filter(a => a.kind === 'context' || (a.status === 'ready' && a.upload)) } export function attachmentPartsForOptimisticTurn(attachments: readonly ChatAttachment[]): Part[] { return attachments.map(attachment => { + if (attachment.kind === 'context') return contextAttachmentParts([attachment.attachment])[0] if (attachment.upload?.kind === 'image' && attachment.previewUrl) { return { type: 'file', @@ -61,7 +65,7 @@ export function withAttachmentDirectives( const sketches: string[] = [] let imagePosition = 0 for (const attachment of attachments) { - if (attachment.upload?.kind !== 'image') continue + if (attachment.kind === 'context' || attachment.upload?.kind !== 'image') continue imagePosition += 1 if (attachment.kind === 'drawing' && attachment.purpose === 'annotation') { annotations.push(`${imagePosition}. ${JSON.stringify(attachment.sourceTab)}`) @@ -180,3 +184,12 @@ export function resolveChatRunOptions( stream } } + +export function messageAttachmentsForSend( + attachments: readonly ChatAttachment[] +): MessageAttachment[] { + return attachments.flatMap(attachment => { + if (attachment.kind === 'context') return [{ type: 'context', ...attachment.attachment }] + return attachment.upload ? [{ type: 'upload', uploadId: attachment.upload.id }] : [] + }) +} diff --git a/client/features/chat/chat-store.ts b/client/features/chat/chat-store.ts index 99ece1e6..d81a92e4 100644 --- a/client/features/chat/chat-store.ts +++ b/client/features/chat/chat-store.ts @@ -1,41 +1,8 @@ +import type { ChatAttachment, ChatAttachmentPatch } from './composer/attachments/types' import { useStore } from 'zustand' import { createStore } from 'zustand/vanilla' -import type { - PreviewBlock, - PreviewFrame, - SessionActivity, - UploadInfo, - WorkspaceTabId -} from '@/lib/types' - -// One composer attachment, tracked per session until the message is sent. A -// file uploads as soon as it's added (drop/paste/pick); an annotation stays a -// local `draft` while it's being drawn and uploads once when the drawing -// session ends. `status` reflects that lifecycle, and `upload` holds the server -// handle once ready. `previewUrl` is a local object URL for image thumbnails -// (revoked on remove/clear). -type ChatAttachmentBase = { - localId: string - name: string - mediaType: string - previewUrl?: string - status: 'draft' | 'uploading' | 'ready' | 'error' - upload?: UploadInfo - error?: string -} - -export type DrawingPurpose = 'annotation' | 'sketch' - -export type ChatAttachment = ChatAttachmentBase & - ( - | { kind: 'file'; purpose?: never; sourceTab?: never } - | { kind: 'drawing'; purpose: DrawingPurpose; sourceTab: WorkspaceTabId } - ) - -type ChatAttachmentPatch = Partial< - Pick -> +import type { PreviewBlock, PreviewFrame, SessionActivity } from '@/lib/types' // App-level ephemeral chat state — the bits that are *pushed* from the server // over the WebSocket and can't be re-fetched as request/response data: @@ -230,13 +197,21 @@ export const liveStore = createStore()(set => ({ const list = s.attachments[k] if (!list) return {} const target = list.find(attachment => attachment.localId === localId) - if (target?.previewUrl && 'previewUrl' in patch && patch.previewUrl !== target.previewUrl) { + if ( + target && + target.kind !== 'context' && + target.previewUrl && + 'previewUrl' in patch && + patch.previewUrl !== target.previewUrl + ) { URL.revokeObjectURL(target.previewUrl) } return { attachments: { ...s.attachments, - [k]: list.map(a => (a.localId === localId ? { ...a, ...patch } : a)) + [k]: list.map(a => + a.localId === localId && a.kind !== 'context' ? { ...a, ...patch } : a + ) } } }), @@ -247,7 +222,8 @@ export const liveStore = createStore()(set => ({ const list = s.attachments[k] if (!list) return {} const target = list.find(a => a.localId === localId) - if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl) + if (target && target.kind !== 'context' && target.previewUrl) + URL.revokeObjectURL(target.previewUrl) return { attachments: { ...s.attachments, [k]: list.filter(a => a.localId !== localId) } } }), @@ -255,7 +231,7 @@ export const liveStore = createStore()(set => ({ set(s => { const k = attachmentKey(workspaceId, sessionId) for (const a of s.attachments[k] ?? []) { - if (a.previewUrl) URL.revokeObjectURL(a.previewUrl) + if (a.kind !== 'context' && a.previewUrl) URL.revokeObjectURL(a.previewUrl) } const next = { ...s.attachments } delete next[k] @@ -289,7 +265,18 @@ export const liveStore = createStore()(set => ({ previews[id] = { ...p, sessionId: to } } } - return { activity, errors, previews } + // Context has no in-flight upload callbacks bound to the old id. + // Move staged context when the provider assigns the chat its real id. + let attachments = s.attachments + const contexts = s.attachments[fromKey]?.filter(a => a.kind === 'context') ?? [] + if (fromKey !== toKey && contexts.length) { + attachments = { + ...s.attachments, + [fromKey]: s.attachments[fromKey].filter(a => a.kind !== 'context'), + [toKey]: [...(s.attachments[toKey] ?? []), ...contexts] + } + } + return { activity, errors, previews, attachments } }) })) diff --git a/client/features/chat/composer/ChatComposer.tsx b/client/features/chat/composer/ChatComposer.tsx index b51db3b4..aab55061 100644 --- a/client/features/chat/composer/ChatComposer.tsx +++ b/client/features/chat/composer/ChatComposer.tsx @@ -1,13 +1,9 @@ +import { ContextAttachmentChip } from './attachments/ContextAttachmentChip' +import { DrawingAttachmentChip } from './attachments/DrawingAttachmentChip' +import { FileAttachmentChip } from './attachments/FileAttachmentChip' import { type RefObject, useRef, useState } from 'react' -import { - IconFile, - IconLoader2, - IconPaperclip, - IconPlayerStopFilled, - IconScribble, - IconX -} from '@tabler/icons-react' +import { IconPaperclip, IconPlayerStopFilled, IconScribble } from '@tabler/icons-react' import { canSubmitComposerAction, @@ -17,19 +13,14 @@ import { ComposerTextarea } from '@/client/components/shared/Composer' import { Button } from '@/client/components/ui/button' -import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/client/components/ui/hover-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { stageComposerFiles } from '@/client/features/chat/composer/attachments/draft-attachments' import { cn } from '@/client/lib/cn' import type { AgentAvailability } from '@/client/lib/agent-availability' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { useLatestRef } from '@/client/lib/use-latest-ref' -import { - type ChatAttachment, - attachmentKey, - liveStore, - useLive -} from '@/client/features/chat/chat-store' +import { attachmentKey, liveStore, useLive } from '@/client/features/chat/chat-store' +import type { ChatAttachment } from '@/client/features/chat/composer/attachments/types' import { useUiStore } from '@/client/store/ui' import { ModelPicker } from './ModelPicker' @@ -92,10 +83,12 @@ export function ChatComposer({ const attachments = useLive(s => s.attachments[attachmentKey(workspaceId, sessionId)] ?? EMPTY) const [dragOver, setDragOver] = useState(false) - const uploading = attachments.some(a => a.status === 'uploading') + const uploading = attachments.some(a => a.kind !== 'context' && a.status === 'uploading') // A draft annotation counts as sendable content: send() finishes the drawing // first, which uploads it before the message goes out. - const hasSendable = attachments.some(a => a.status === 'ready' || a.status === 'draft') + const hasSendable = attachments.some( + a => a.kind === 'context' || a.status === 'ready' || a.status === 'draft' + ) const hasContent = value.trim().length > 0 || hasSendable const canSend = canSubmitComposerAction(hasContent, uploading, availability) @@ -165,9 +158,29 @@ export function ChatComposer({ > {attachments.length > 0 && (
- {attachments.map(a => ( - removeAttachment(a)} /> - ))} + {attachments + .toSorted((a, b) => ATTACHMENT_ORDER[a.kind] - ATTACHMENT_ORDER[b.kind]) + .map(a => + a.kind === 'context' ? ( + removeAttachment(a)} + /> + ) : a.kind === 'drawing' ? ( + removeAttachment(a)} + /> + ) : ( + removeAttachment(a)} + /> + ) + )}
)} @@ -272,110 +285,8 @@ export function ChatComposer({ } const EMPTY: ChatAttachment[] = [] - -type AttachmentChipProps = { - attachment: ChatAttachment - onRemove: () => void -} - -// A composer attachment preview: an image thumbnail or a labelled file chip, -// with an upload spinner / error overlay and a remove button. -function AttachmentChip({ attachment, onRemove }: AttachmentChipProps) { - if (attachment.kind === 'drawing') { - return - } - - const { name, previewUrl, status, error } = attachment - const isImage = !!previewUrl - - return ( -
- {isImage ? ( - {name} - ) : ( - <> - - {name} - - )} - - {status === 'uploading' && ( -
- -
- )} - {status === 'error' && ( -
- Failed -
- )} - - -
- ) -} - -type DrawingAttachmentChipProps = { - attachment: Extract - onRemove: () => void -} - -function DrawingAttachmentChip({ attachment, onRemove }: DrawingAttachmentChipProps) { - const { previewUrl, purpose } = attachment - const label = purpose === 'sketch' ? 'Sketch' : 'Annotation' - - return ( - - - } - > - - {label} - - {previewUrl && ( - - {`${label} - - )} - - ) +const ATTACHMENT_ORDER: Record = { + file: 0, + drawing: 1, + context: 2 } diff --git a/client/features/chat/composer/attachments/context-attachments.test.ts b/client/features/chat/composer/attachments/context-attachments.test.ts new file mode 100644 index 00000000..92893b52 --- /dev/null +++ b/client/features/chat/composer/attachments/context-attachments.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { appletRuntime } from '../../../applets/applet-runtime' +import { stageChatContext } from './draft-attachments' +import { attachmentKey, liveStore } from '../../chat-store' +import { + attachmentsForSend, + attachmentPartsForOptimisticTurn, + withAttachmentDirectives +} from '../../chat-send' + +const workspaceId = 'context-test' +const target = { workspaceId, sessionId: null } +const attachment = { source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } } +afterEach(() => liveStore.setState({ attachments: {} })) + +describe('context staging and sends', () => { + test('stamps source, snapshots at the bridge and ignores disposed connections', () => { + const runtime = appletRuntime(workspaceId) + const off = runtime.on('addChatContext', value => { + stageChatContext(target, value) + }) + const { bridge, dispose } = runtime.connect({ kind: 'view', name: 'orders' }) + const input = { label: 'Order', context: { status: 'pending' }, source: 'widget:forged' } + bridge.addChatContext(input) + input.context.status = 'done' + dispose() + bridge.addChatContext({ label: 'Another', context: {} }) + off() + expect(attachmentsForSend(workspaceId, null)).toMatchObject([ + { + kind: 'context', + attachment: { source: 'view:orders', label: 'Order', context: { status: 'pending' } } + } + ]) + }) + + test('skips identical repeats and stages more than ten different payloads', () => { + stageChatContext(target, attachment) + stageChatContext(target, structuredClone(attachment)) + expect(attachmentsForSend(workspaceId, null)).toHaveLength(1) + for (let i = 1; i < 20; i++) + stageChatContext(target, { ...attachment, context: { orderId: String(i) } }) + expect(attachmentsForSend(workspaceId, null)).toHaveLength(20) + stageChatContext(target, attachment) + expect(attachmentsForSend(workspaceId, null)).toHaveLength(20) + }) + + test('keeps chats isolated and follows session renames', () => { + stageChatContext(target, attachment) + stageChatContext({ workspaceId, sessionId: 'existing' }, { ...attachment, label: 'Existing' }) + expect(attachmentsForSend(workspaceId, 'different')).toEqual([]) + liveStore.getState().renameSession(workspaceId, 'existing', 'renamed') + expect(attachmentsForSend(workspaceId, 'renamed')[0].name).toBe('Existing') + expect(attachmentsForSend(workspaceId, null)[0].name).toBe(attachment.label) + liveStore.getState().clearAttachments(workspaceId, null) + expect(attachmentsForSend(workspaceId, 'renamed')).toHaveLength(1) + }) + + test('sends context with uploads without changing annotation image positions', () => { + stageChatContext(target, attachment) + liveStore.getState().addAttachments(workspaceId, null, [ + { + kind: 'drawing', + purpose: 'annotation', + sourceTab: 'view:orders', + localId: 'image', + name: 'Annotation.png', + mediaType: 'image/png', + status: 'ready', + upload: { + id: 'up', + kind: 'image', + mediaType: 'image/png', + filename: 'Annotation.png', + size: 1 + } + } + ]) + const ready = attachmentsForSend(workspaceId, null) + expect(ready).toHaveLength(2) + expect(attachmentPartsForOptimisticTurn(ready)[0]).toEqual({ type: 'context', ...attachment }) + expect(withAttachmentDirectives(undefined, ready)?.directives).toEqual([ + 'Annotation attachment sources in attachment order: 1. "view:orders".' + ]) + expect(attachmentsForSend(workspaceId, null, { applet: { source: 'widget:clock' } })).toEqual( + [] + ) + expect(liveStore.getState().attachments[attachmentKey(workspaceId, null)]).toHaveLength(2) + }) +}) diff --git a/client/features/chat/composer/attachments/draft-attachments.test.ts b/client/features/chat/composer/attachments/draft-attachments.test.ts index e1386d03..bacf3919 100644 --- a/client/features/chat/composer/attachments/draft-attachments.test.ts +++ b/client/features/chat/composer/attachments/draft-attachments.test.ts @@ -7,6 +7,12 @@ const workspaceId = 'workspace-1' const sessionId = 'session-1' const originalFetch = globalThis.fetch +function drawingAttachments() { + return (liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] ?? []).filter( + a => a.kind !== 'context' + ) +} + afterEach(() => { globalThis.fetch = originalFetch liveStore.getState().clearAttachments(workspaceId, sessionId) @@ -27,7 +33,7 @@ describe('drawing draft staging', () => { sourceTab: 'overview', blob: new Blob(['first'], { type: 'image/png' }) }) - const first = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0] + const first = drawingAttachments()[0] expect(first.status).toBe('draft') expect(first.previewUrl).toStartWith('blob:') @@ -39,7 +45,7 @@ describe('drawing draft staging', () => { sourceTab: 'overview', blob: new Blob(['second'], { type: 'image/png' }) }) - const list = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] + const list = drawingAttachments() expect(list).toHaveLength(1) expect(list[0].previewUrl).not.toBe(first.previewUrl) expect(revokeSpy).toHaveBeenCalledWith(first.previewUrl) @@ -72,7 +78,7 @@ describe('drawing draft staging', () => { isCurrent: () => true }) - const list = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] + const list = drawingAttachments() expect(list).toHaveLength(1) expect(list[0].status).toBe('ready') expect(list[0].upload?.id).toBe('upload-1') @@ -97,8 +103,7 @@ describe('drawing attachment staging', () => { blob: new Blob(['first'], { type: 'image/png' }), isCurrent: () => revision === 1 }) - const firstPreview = - liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0].previewUrl + const firstPreview = drawingAttachments()[0].previewUrl revision = 2 const second = stageDrawing({ @@ -110,14 +115,11 @@ describe('drawing attachment staging', () => { blob: new Blob(['second'], { type: 'image/png' }), isCurrent: () => revision === 2 }) - const secondPreview = - liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0].previewUrl + const secondPreview = drawingAttachments()[0].previewUrl expect(secondPreview).not.toBe(firstPreview) expect(revokeSpy).toHaveBeenCalledWith(firstPreview) - expect(liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0].status).toBe( - 'uploading' - ) + expect(drawingAttachments()[0].status).toBe('uploading') requests[1]( Response.json([{ id: 'upload-2', kind: 'image', mediaType: 'image/png' }], { status: 200 }) @@ -128,7 +130,7 @@ describe('drawing attachment staging', () => { ) await first - const attachment = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0] + const attachment = drawingAttachments()[0] expect(attachment.status).toBe('ready') expect(attachment.upload?.id).toBe('upload-2') revokeSpy.mockRestore() @@ -150,14 +152,13 @@ describe('drawing attachment staging', () => { isCurrent: () => true }) - const optimisticAttachment = - liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0] + const optimisticAttachment = drawingAttachments()[0] expect(optimisticAttachment.status).toBe('uploading') expect(optimisticAttachment.previewUrl).toStartWith('blob:') await upload - const attachment = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0] + const attachment = drawingAttachments()[0] expect(attachment).toBeUndefined() expect(revokeSpy).toHaveBeenCalledWith(optimisticAttachment.previewUrl) revokeSpy.mockRestore() @@ -180,7 +181,7 @@ describe('drawing attachment staging', () => { isCurrent: () => true }) - const attachment = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)][0] + const attachment = drawingAttachments()[0] expect(attachment.kind).toBe('drawing') expect(attachment.name).toBe('Sketch.png') expect(attachment.kind === 'drawing' ? attachment.purpose : null).toBe('sketch') diff --git a/client/features/chat/composer/attachments/draft-attachments.ts b/client/features/chat/composer/attachments/draft-attachments.ts index c6987dcb..2427da17 100644 --- a/client/features/chat/composer/attachments/draft-attachments.ts +++ b/client/features/chat/composer/attachments/draft-attachments.ts @@ -1,6 +1,11 @@ +import type { ContextAttachment } from '@/lib/types' import type { WorkspaceTabId } from '@/lib/types' -import { attachmentKey, type ChatAttachment, type DrawingPurpose, liveStore } from '../../chat-store' +import { attachmentKey, liveStore } from '../../chat-store' +import type { + ChatAttachment, + DrawingPurpose +} from '@/client/features/chat/composer/attachments/types' import { uploadFiles } from './uploads' type ComposerTarget = { @@ -151,3 +156,27 @@ export async function stageDrawing({ liveStore.getState().removeAttachment(workspaceId, sessionId, localId) } } + +// Duplicate context is ignored; the caller still reveals chat. +export function stageChatContext( + { workspaceId, sessionId }: ComposerTarget, + attachment: ContextAttachment +): void { + const store = liveStore.getState() + const pending = (store.attachments[attachmentKey(workspaceId, sessionId)] ?? []).filter( + item => item.kind === 'context' + ) + const json = JSON.stringify(attachment.context) + if ( + pending.some( + item => + item.attachment.source === attachment.source && + item.attachment.label === attachment.label && + JSON.stringify(item.attachment.context) === json + ) + ) + return + store.addAttachments(workspaceId, sessionId, [ + { kind: 'context', localId: crypto.randomUUID(), name: attachment.label, attachment } + ]) +} diff --git a/client/features/chat/composer/attachments/types.ts b/client/features/chat/composer/attachments/types.ts new file mode 100644 index 00000000..7530faf4 --- /dev/null +++ b/client/features/chat/composer/attachments/types.ts @@ -0,0 +1,33 @@ +import type { ContextAttachment, UploadInfo, WorkspaceTabId } from '@/lib/types' + +// One composer attachment, tracked per session until the message is sent. A +// file uploads as soon as it's added (drop/paste/pick); an annotation stays a +// local `draft` while it's being drawn and uploads once when the drawing +// session ends. `status` reflects that lifecycle, and `upload` holds the server +// handle once ready. `previewUrl` is a local object URL for image thumbnails +// (revoked on remove/clear). +type UploadAttachmentBase = { + localId: string + name: string + mediaType: string + previewUrl?: string + status: 'draft' | 'uploading' | 'ready' | 'error' + upload?: UploadInfo + error?: string +} + +export type DrawingPurpose = 'annotation' | 'sketch' + +export type UploadedChatAttachment = UploadAttachmentBase & + ( + | { kind: 'file'; purpose?: never; sourceTab?: never } + | { kind: 'drawing'; purpose: DrawingPurpose; sourceTab: WorkspaceTabId } + ) + +export type ChatAttachment = + | UploadedChatAttachment + | { kind: 'context'; localId: string; name: string; attachment: ContextAttachment } + +export type ChatAttachmentPatch = Partial< + Pick +> diff --git a/client/features/chat/messages/ExploreWorkspaceState.test.tsx b/client/features/chat/messages/ExploreWorkspaceState.test.tsx index 18ca4338..01fdb4c5 100644 --- a/client/features/chat/messages/ExploreWorkspaceState.test.tsx +++ b/client/features/chat/messages/ExploreWorkspaceState.test.tsx @@ -3,7 +3,10 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'bun:test' -import { ChatEmptyState, WORKSPACE_ANALYSIS_PROMPT } from '@/client/features/chat/messages/ChatEmptyState' +import { + ChatEmptyState, + WORKSPACE_ANALYSIS_PROMPT +} from '@/client/features/chat/messages/ChatEmptyState' import { renderMoiContext } from '@/lib/moi-context' function renderExploreWorkspace(disabled = false): string { diff --git a/client/features/chat/messages/TurnView.tsx b/client/features/chat/messages/TurnView.tsx index 5d7d26b5..da52865b 100644 --- a/client/features/chat/messages/TurnView.tsx +++ b/client/features/chat/messages/TurnView.tsx @@ -1,3 +1,4 @@ +import { ContextAttachmentChip } from '../composer/attachments/ContextAttachmentChip' import { memo } from 'react' import { IconChevronRight, IconFile } from '@tabler/icons-react' @@ -167,13 +168,17 @@ export const TurnView = memo(function TurnView({ turn, processing = false }: Tur const fileParts = turn.parts.filter( (p): p is Extract => p.type === 'file' ) + const contextParts = turn.parts.filter(p => p.type === 'context') const text = turn.parts .filter(p => p.type === 'text') .map(p => (p.type === 'text' ? p.text : '')) .join('\n') - if (!text && fileParts.length === 0) return null + if (!text && fileParts.length === 0 && contextParts.length === 0) return null return (
+ {contextParts.map((p, i) => ( + + ))} {fileParts.map((p, i) => ( ))} @@ -207,6 +212,8 @@ type PartRendererProps = { part: Part } // folded into a run by buildSegments. function PartRenderer({ part }: PartRendererProps) { switch (part.type) { + case 'context': + return case 'text': return case 'tool-call': diff --git a/client/features/chat/messages/WelcomeState.test.tsx b/client/features/chat/messages/WelcomeState.test.tsx index 0dd384ad..a7470ff0 100644 --- a/client/features/chat/messages/WelcomeState.test.tsx +++ b/client/features/chat/messages/WelcomeState.test.tsx @@ -3,7 +3,10 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, test } from 'bun:test' -import { CHAT_WELCOME_PROMPTS, ChatEmptyState } from '@/client/features/chat/messages/ChatEmptyState' +import { + CHAT_WELCOME_PROMPTS, + ChatEmptyState +} from '@/client/features/chat/messages/ChatEmptyState' import { renderMoiContext } from '@/lib/moi-context' function renderWelcome(disabled = false): string { diff --git a/client/features/chat/messages/interleave-notices.test.ts b/client/features/chat/messages/interleave-notices.test.ts index 723433e4..4fb753a8 100644 --- a/client/features/chat/messages/interleave-notices.test.ts +++ b/client/features/chat/messages/interleave-notices.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { chatNoticeLabel, interleaveNotices } from '@/client/features/chat/messages/interleave-notices' +import { + chatNoticeLabel, + interleaveNotices +} from '@/client/features/chat/messages/interleave-notices' import type { SystemNotice, Turn } from '@/lib/types' function turn(id: string, timestamp?: string): Turn { diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 3a0ace68..6dbbe0b5 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -1,9 +1,14 @@ +import { messageAttachmentsForSend } from './chat-send' import { useCallback, useEffect, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' -import { useSessionConfig, useSessionView, useWorkspaceSessions } from '@/client/features/chat/sessions/api' +import { + useSessionConfig, + useSessionView, + useWorkspaceSessions +} from '@/client/features/chat/sessions/api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { useSelectedSession } from '@/client/features/chat/sessions/useSelectedSession' import { @@ -150,6 +155,7 @@ export function useChat(address: WorkspaceTabAddress) { pickedEffort, pickedFastMode ) + const attachments = messageAttachmentsForSend(ready) sendMessage({ type: 'chat', workspaceId, @@ -162,7 +168,7 @@ export function useChat(address: WorkspaceTabAddress) { fastMode, stream, context: buildMoiContext(withAttachmentDirectives(options, ready)), - ...(ready.length > 0 ? { attachments: ready.map(a => a.upload!.id) } : {}) + ...(attachments.length > 0 ? { attachments } : {}) }) useUiStore.getState().markMessageSentFromMoi(workspaceId) if (isNew) { diff --git a/client/features/dev/chat-states-fixtures.ts b/client/features/dev/chat-states-fixtures.ts index 53186901..fe4a5ab8 100644 --- a/client/features/dev/chat-states-fixtures.ts +++ b/client/features/dev/chat-states-fixtures.ts @@ -525,6 +525,12 @@ const moonReplyWithMeta: Turn = { // Beat 12 — attachments: image thumbnail + non-image file chip on one user turn. const attachmentsTurn = turn('user', 23, 26, [ + { + type: 'context', + source: 'view:board', + label: 'Selected board items', + context: { itemIds: ['item-1', 'item-2'] } + }, { type: 'file', mediaType: 'image/png', url: CHECKERBOARD_PNG, filename: 'pattern.png' }, { type: 'file', mediaType: 'application/pdf', url: '', filename: 'report.pdf' }, text( diff --git a/client/features/drawings/useChatAnnotation.ts b/client/features/drawings/useChatAnnotation.ts index b682f944..da549fbd 100644 --- a/client/features/drawings/useChatAnnotation.ts +++ b/client/features/drawings/useChatAnnotation.ts @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { toast } from '@/client/components/ui/toast' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/draft-attachments' +import { + stageDrawing, + stageDrawingDraft +} from '@/client/features/chat/composer/attachments/draft-attachments' import { liveStore } from '@/client/features/chat/chat-store' import type { ComposerAnnotationControls } from '@/client/features/chat/composer/ChatComposer' import { useLatestRef } from '@/client/lib/use-latest-ref' diff --git a/client/features/drawings/useViewBuilderSketch.ts b/client/features/drawings/useViewBuilderSketch.ts index f188c84c..cd2d3925 100644 --- a/client/features/drawings/useViewBuilderSketch.ts +++ b/client/features/drawings/useViewBuilderSketch.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { stageDrawing, stageDrawingDraft } from '@/client/features/chat/composer/attachments/draft-attachments' +import { + stageDrawing, + stageDrawingDraft +} from '@/client/features/chat/composer/attachments/draft-attachments' import { attachmentKey, liveStore } from '@/client/features/chat/chat-store' import { useLatestRef } from '@/client/lib/use-latest-ref' import type { WorkspaceTabId } from '@/lib/types' @@ -76,6 +79,7 @@ export function useViewBuilderSketch({ .attachments[attachmentKey(workspaceId, sessionId)]?.some( attachment => attachment.localId === attachmentIdRef.current && + attachment.kind !== 'context' && (attachment.status === 'ready' || attachment.status === 'uploading') ) if (staged) return pendingStageRef.current ?? Promise.resolve() diff --git a/client/features/views/useViewBuilderActions.ts b/client/features/views/useViewBuilderActions.ts index ad4ff782..f528541f 100644 --- a/client/features/views/useViewBuilderActions.ts +++ b/client/features/views/useViewBuilderActions.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { attachmentPartsForOptimisticTurn, attachmentsForSend, + messageAttachmentsForSend, resolveChatRunOptions, startOptimisticSession, startOptimisticTurn @@ -70,7 +71,9 @@ export function useViewBuilderActions() { requirements: text, optimisticId, ...(attachments.length > 0 - ? { attachments: attachments.map(attachment => attachment.upload!.id) } + ? { + attachments: messageAttachmentsForSend(attachments) + } : {}), model, effort, diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 4fbbeb04..4c6dc79b 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -24,7 +24,10 @@ import { PanelHeader } from '@/client/components/shared/PanelHeader' import { WorkspaceIcon } from '@/client/components/shared/WorkspaceIcon' import { Button } from '@/client/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' -import { useAppletChatMessage } from '@/client/features/chat/applet-chat-intents' +import { + useAppletChatMessage, + useAppletChatContext +} from '@/client/features/chat/applet-chat-intents' import { useChat } from '@/client/features/chat/useChat' import { ViewBuilder, type ViewBuilderHandle } from '@/client/features/views/ViewBuilder' import { ViewManager } from '@/client/features/views/ViewManager' @@ -454,6 +457,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // in full-screen mode the chat is a closed popover, and a run the user can't // see is worse than a panel that opens itself. useAppletChatMessage({ send, revealChat: openChat, agentAvailability }) + useAppletChatContext(sessionId, openChat) const createItems: CreateWorkspaceTabItem[] = [ ...(!dockedSplit && !openSet.has('agent') diff --git a/client/lib/connection.stream.test.ts b/client/lib/connection.stream.test.ts index 5cbb1558..f0854276 100644 --- a/client/lib/connection.stream.test.ts +++ b/client/lib/connection.stream.test.ts @@ -11,7 +11,10 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, test } from 'bun:test' import { workspaceKeys } from '@/client/api/workspace-keys' -import { __setQueryClientForTests, handleFrame } from '@/client/features/chat/connection/chat-connection' +import { + __setQueryClientForTests, + handleFrame +} from '@/client/features/chat/connection/chat-connection' import { liveStore } from '@/client/features/chat/chat-store' import { emptyViewState } from '@/lib/format' import type { PreviewBlock, Turn, ViewState } from '@/lib/types' diff --git a/lib/chat-title.ts b/lib/chat-title.ts index d99f43f6..fb7154ab 100644 --- a/lib/chat-title.ts +++ b/lib/chat-title.ts @@ -1,6 +1,7 @@ +import { stripContextAttachmentsLoose } from './moi-attachments' const MAX_CHAT_TITLE_LENGTH = 64 export function formatChatTitle(text: string, filenames: readonly string[] = []): string { - const source = text.trim() || filenames.filter(Boolean).join(', ') + const source = stripContextAttachmentsLoose(text).trim() || filenames.filter(Boolean).join(', ') return source.replace(/\s+/gu, ' ').trim().slice(0, MAX_CHAT_TITLE_LENGTH).trimEnd() } diff --git a/lib/message-attachments.test.ts b/lib/message-attachments.test.ts new file mode 100644 index 00000000..58a19d9a --- /dev/null +++ b/lib/message-attachments.test.ts @@ -0,0 +1,35 @@ +import type { MessageAttachment } from './types' +import { expect, test } from 'bun:test' +import { isMessageAttachments, partitionMessageAttachments } from './message-attachments' + +test('partitions mixed attachments and leaves saved context payloads unchanged', () => { + const context = { source: 'view:orders', label: 'Order', context: { id: '1042' } } + const attachments: MessageAttachment[] = [ + { type: 'upload', uploadId: 'file' }, + { type: 'context', ...context }, + { type: 'upload', uploadId: 'drawing' } + ] + expect(isMessageAttachments(attachments)).toBe(true) + expect(partitionMessageAttachments(attachments)).toEqual({ + uploadIds: ['file', 'drawing'], + contextAttachments: [context] + }) + expect(partitionMessageAttachments()).toEqual({ uploadIds: [], contextAttachments: [] }) +}) + +test('rejects invalid variants, legacy ids, and invalid context in mixed requests', () => { + for (const value of [ + null, + ['file'], + [{ type: 'upload' }], + [{ type: 'upload', uploadId: '' }], + [{ type: 'unknown' }], + [{ type: 'context', label: 'Bad' }] + ]) { + expect(isMessageAttachments(value)).toBe(false) + } + const item = { type: 'context', source: 'view:orders', label: 'Order', context: {} } + expect(isMessageAttachments(Array(20).fill(item))).toBe(true) + expect(isMessageAttachments([{ ...item, context: { text: 'x'.repeat(5001) } }])).toBe(false) + expect(isMessageAttachments([])).toBe(true) +}) diff --git a/lib/message-attachments.ts b/lib/message-attachments.ts new file mode 100644 index 00000000..20402d9d --- /dev/null +++ b/lib/message-attachments.ts @@ -0,0 +1,31 @@ +import { isContextAttachments } from './moi-attachments' +import type { ContextAttachment } from './types' + +import type { MessageAttachment } from './types' + +export function isMessageAttachments(value: unknown): value is MessageAttachment[] { + if (!Array.isArray(value)) return false + const contexts: unknown[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') return false + if (item.type === 'upload') { + if (typeof item.uploadId !== 'string' || !item.uploadId) return false + } else if (item.type === 'context') { + contexts.push(item) + } else return false + } + return isContextAttachments(contexts) +} + +export function partitionMessageAttachments(attachments: readonly MessageAttachment[] = []) { + const uploadIds: string[] = [] + const contextAttachments: ContextAttachment[] = [] + for (const attachment of attachments) { + if (attachment.type === 'upload') uploadIds.push(attachment.uploadId) + else { + const { source, label, context } = attachment + contextAttachments.push({ source, label, context }) + } + } + return { uploadIds, contextAttachments } +} diff --git a/lib/moi-attachments.test.ts b/lib/moi-attachments.test.ts new file mode 100644 index 00000000..89f79641 --- /dev/null +++ b/lib/moi-attachments.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { + appendContextAttachments, + contextAttachmentParts, + isContextAttachments, + snapshotChatContext, + splitContextAttachments, + stripContextAttachmentsLoose +} from './moi-attachments' +import { appendAttachmentNote, splitAttachmentNote } from './attachment-note' +import { formatChatTitle } from './chat-title' + +const attachment = { source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } } + +describe('context attachments', () => { + test('captures a detached JSON snapshot', () => { + const input = { label: ' Order ', context: { nested: { status: 'pending' } } } + const captured = snapshotChatContext(input) + input.context.nested.status = 'done' + expect(captured).toEqual({ label: 'Order', context: { nested: { status: 'pending' } } }) + }) + + test('rejects invalid, lossy and oversized payloads', () => { + const cycle: Record = {} + cycle.self = cycle + for (const context of [ + null, + [], + 'text', + { fn: () => {} }, + { n: NaN }, + { n: Infinity }, + { n: 1n }, + { missing: undefined }, + { date: new Date() }, + cycle, + { long: 'x'.repeat(5000) } + ]) { + expect(snapshotChatContext({ label: 'Order', context })).toBeNull() + } + for (const label of ['', ' ', 'x'.repeat(121), 5]) + expect(snapshotChatContext({ label, context: {} })).toBeNull() + expect( + snapshotChatContext({ label: 'x'.repeat(120), context: { text: 'x'.repeat(4989) } }) + ).not.toBeNull() + expect(snapshotChatContext({ label: 'Order', context: { text: 'x'.repeat(4990) } })).toBeNull() + expect(isContextAttachments(Array(20).fill(attachment))).toBe(true) + expect(isContextAttachments([{ ...attachment, source: '' }])).toBe(false) + }) + + test('round-trips multiple items and escapes delimiters in labels and data', () => { + const attachments = [ + attachment, + { + ...attachment, + label: '', + context: { text: '' } + } + ] + const wire = appendContextAttachments('Help with these', attachments) + expect(wire.match(/<\/moi-attachments>/g)).toHaveLength(1) + expect(splitContextAttachments(wire)).toEqual({ text: 'Help with these', attachments }) + expect(contextAttachmentParts(attachments)).toEqual( + attachments.map(a => ({ type: 'context', ...a })) + ) + expect(splitContextAttachments(appendContextAttachments('', attachments)).text).toBe('') + }) + + test('leaves ordinary, malformed and future-version text intact', () => { + for (const text of [ + 'hello', + '', + 'broken', + '{"version":2,"attachments":[]}', + '{"version":1,"attachments":[{}]}' + ]) { + expect(splitContextAttachments(text)).toEqual({ text, attachments: [] }) + } + }) + + test('coexists with file notes and hides metadata from truncated previews', () => { + const files = [{ filename: 'notes.txt', path: '/tmp/notes.txt' }] + const wire = appendAttachmentNote(appendContextAttachments('Review', [attachment]), files) + expect(splitAttachmentNote(splitContextAttachments(wire).text)).toEqual({ + text: 'Review', + files + }) + const truncated = appendContextAttachments('Review', [attachment]).slice(0, -15) + expect(stripContextAttachmentsLoose(truncated)).toBe('Review') + expect(formatChatTitle(truncated)).toBe('Review') + }) +}) diff --git a/lib/moi-attachments.ts b/lib/moi-attachments.ts new file mode 100644 index 00000000..bd5c207e --- /dev/null +++ b/lib/moi-attachments.ts @@ -0,0 +1,120 @@ +// Durable, user-selected context. Unlike ambient moi context, this belongs to +// one message and is retained in the provider's transcript. +import type { Part } from './format' +import { isParamsRecord } from './workspace-tabs' + +import type { ChatContextInput, ContextAttachment } from './types' +export const MAX_CONTEXT_ATTACHMENT_CHARS = 5000 +export const MAX_CONTEXT_LABEL_CHARS = 120 +const OPEN = '' +const CLOSE = '' + +// Reject values JSON would silently change (functions, undefined, Dates, +// non-finite numbers, etc.). Repeated references are fine; cycles are not. +function isJson(value: unknown, parents = new Set()): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) + if (typeof value !== 'object' || parents.has(value)) return false + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) + return false + parents.add(value) + const valid = + Object.getOwnPropertySymbols(value).length === 0 && + (Array.isArray(value) ? Array.from(value) : Object.values(value)).every(v => isJson(v, parents)) + parents.delete(value) + return valid +} + +export function snapshotChatContext(value: unknown): ChatContextInput | null { + try { + if (!isParamsRecord(value) || typeof value.label !== 'string') return null + const label = value.label.trim() + if ( + !label || + label.length > MAX_CONTEXT_LABEL_CHARS || + !isParamsRecord(value.context) || + !isJson(value.context) + ) + return null + const json = JSON.stringify(value.context) + if (json.length > MAX_CONTEXT_ATTACHMENT_CHARS) return null + return { label, context: JSON.parse(json) as Record } + } catch { + return null + } +} + +export function isContextAttachments(value: unknown): value is ContextAttachment[] { + return ( + Array.isArray(value) && + value.every( + item => + isParamsRecord(item) && + typeof item.source === 'string' && + /^(widget|view):[^/]+$/.test(item.source) && + snapshotChatContext(item) !== null + ) + ) +} + +export function contextAttachmentParts(attachments: readonly ContextAttachment[] = []): Part[] { + return attachments.map(({ source, label, context }) => ({ + type: 'context', + source, + label, + context + })) +} + +export function appendContextAttachments( + text: string, + attachments: readonly ContextAttachment[] = [] +): string { + if (!attachments.length) return text + if (!isContextAttachments(attachments)) throw new Error('Invalid context attachments') + const json = JSON.stringify({ version: 1, attachments }).replaceAll('<', '\\u003c') + return `${text}\n\n${OPEN}\n${json}\n${CLOSE}`.trim() +} + +// Only a complete, valid v1 block is hidden. Ordinary text and malformed or +// future-version blocks are left intact instead of silently eating user text. +export function splitContextAttachments(text: string): { + text: string + attachments: ContextAttachment[] +} { + const attachments: ContextAttachment[] = [] + const cleaned = text.replace( + /\s*([\s\S]*?)\s*<\/moi-attachments>/g, + (block: string, json: string) => { + try { + const parsed: unknown = JSON.parse(json) + if ( + !isParamsRecord(parsed) || + parsed.version !== 1 || + !isContextAttachments(parsed.attachments) + ) + return block + attachments.push(...parsed.attachments) + return '' + } catch { + return block + } + } + ) + return { text: attachments.length ? cleaned.trim() : text, attachments } +} + +export function stripContextAttachments(text: string): string { + return splitContextAttachments(text).text +} + +// Truncated session previews may end halfway through the metadata block. +export function stripContextAttachmentsLoose(text: string): string { + const clean = stripContextAttachments(text) + const start = clean.indexOf(OPEN) + return (start < 0 ? clean : clean.slice(0, start)).trimEnd() +} diff --git a/server/api-view-builders.test.ts b/server/api-view-builders.test.ts index e3035b21..8202b6ee 100644 --- a/server/api-view-builders.test.ts +++ b/server/api-view-builders.test.ts @@ -66,7 +66,7 @@ describe('view builder sketch submission', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: { requirements: '' }, - attachments: [upload.id], + attachments: [{ type: 'upload', uploadId: upload.id }], availableIcons: ['chart'] }) } @@ -74,13 +74,56 @@ describe('view builder sketch submission', () => { if (!response.ok) throw new Error(await response.text()) expect(response.status).toBe(200) - expect(sent[0]?.attachments).toEqual([upload.id]) + expect(sent[0]?.attachments).toEqual([{ type: 'upload', uploadId: upload.id }]) expect(sent[0]?.content).toBe('') expect(sent[0]?.context?.directives).toContain( - "The attached image is the user's sketch of the intended view layout." + 'Use the attachments as reference material for the intended view.' ) }) + test.each([false, true])('forwards context with optional file uploads: %s', async withUpload => { + const workspacePath = join(tempDir, 'workspace') + await mkdir(workspacePath) + const workspace = await registerWorkspace(workspacePath, { type: 'codex' }) + const created = await api.request(`/api/workspaces/${workspace.id}/view-builders`, { + method: 'POST' + }) + const draft = (await created.json()) as ViewBuilder + const attachments: NonNullable = [ + { type: 'context', source: 'view:orders', label: 'Order', context: { id: '1042' } } + ] + if (withUpload) { + for (const filename of ['requirements.txt', 'notes.txt']) { + const upload = await addUpload({ + workspaceId: workspace.id, + filename, + mediaType: 'text/plain', + bytes: Buffer.from('Reference material') + }) + attachments.push({ type: 'upload', uploadId: upload.id }) + } + } + const sent: SendMessageInput[] = [] + codexHarness.sendMessage = async input => { + sent.push(input) + } + const response = await api.request( + `/api/workspaces/${workspace.id}/view-builders/${draft.id}/submit`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input: { requirements: '' }, + attachments, + availableIcons: ['chart'] + }) + } + ) + expect(response.status).toBe(200) + expect(sent[0]?.attachments).toEqual(attachments) + expect(sent[0]?.content).toBe('') + }) + test('rejects a first message with neither text nor a sketch', async () => { const workspace = await registerWorkspace(join(tempDir, 'workspace'), { type: 'codex' }) const createResponse = await api.request(`/api/workspaces/${workspace.id}/view-builders`, { @@ -98,7 +141,7 @@ describe('view builder sketch submission', () => { ) expect(response.status).toBe(400) - expect(await response.text()).toBe('View requirements or a sketch are required') + expect(await response.text()).toBe('View requirements or an attachment are required') }) test('rejects malformed sketch upload ids', async () => { @@ -122,7 +165,7 @@ describe('view builder sketch submission', () => { ) expect(response.status).toBe(400) - expect(await response.text()).toBe('Invalid sketch attachment') + expect(await response.text()).toBe('Invalid attachments') }) }) diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index f4cbd3e0..c08ba286 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -125,7 +125,7 @@ export function rpc(module, name) { // per-segment URL-encoded so spaces / unicode in filenames survive. A leading // slash is stripped so both `clips/a.mp4` and `/clips/a.mp4` work. // -// `focusTab(tab, params?)` and `sendChatMessage(message, context?)` forward to +// `focusTab(tab, params?)` and `sendChatMessage({ message, context? })` forward to // this bundle's host-attached bridge — client-local replace-navigation to a // workspace tab (params delivered to the target view via navigation state), // and a chat message sent to the workspace's active chat as if the user had @@ -158,8 +158,12 @@ export function focusTab(tab, params) { bridge?.focusTab(tab, params); } -export function sendChatMessage(message, context) { - bridge?.sendChatMessage(message, context); +export function addChatContext(input) { + bridge?.addChatContext(input); +} + +export function sendChatMessage(input, context) { + bridge?.sendChatMessage(input, context); } ` diff --git a/server/harness/acp/adapter.ts b/server/harness/acp/adapter.ts index 62739863..49d3fce8 100644 --- a/server/harness/acp/adapter.ts +++ b/server/harness/acp/adapter.ts @@ -7,6 +7,11 @@ // call both closes the open assistant turn and becomes a turn of its own — // the same item-per-turn model the Codex adapter produces. +import { + splitContextAttachments, + contextAttachmentParts, + stripContextAttachmentsLoose +} from '@/lib/moi-attachments' import { splitAttachmentNote } from '@/lib/attachment-note' import type { Part, ToolCall, ToolState, Turn, TurnMeta } from '@/lib/format' import { stripMoiContext } from '@/lib/moi-context' @@ -118,13 +123,15 @@ export function acpUsageToTurnMeta(usage: Usage | null | undefined): TurnMeta['u // carries the envelope as its lone text) folds to no parts — the caller skips // the bubble entirely. export function replayedUserParts(raw: string): Part[] { - const split = splitAttachmentNote(stripMoiContext(raw)) + const attached = splitContextAttachments(stripMoiContext(raw)) + const split = splitAttachmentNote(attached.text) const parts: Part[] = split.files.map(f => ({ type: 'file', mediaType: 'application/octet-stream', url: f.path, filename: f.filename })) + parts.push(...contextAttachmentParts(attached.attachments)) if (split.text.trim()) parts.push({ type: 'text', text: split.text }) return parts } @@ -133,7 +140,7 @@ export function acpSessionToSessionInfo(entry: AcpSessionListEntry): SessionInfo const updated = entry.updatedAt ? Date.parse(entry.updatedAt) : NaN return { sessionId: entry.sessionId, - summary: entry.title?.trim() || 'Untitled session', + summary: stripContextAttachmentsLoose(entry.title?.trim() ?? '') || 'Untitled session', lastModified: Number.isNaN(updated) ? 0 : updated, ...(entry.cwd ? { cwd: entry.cwd } : {}) } diff --git a/server/harness/acp/session.test.ts b/server/harness/acp/session.test.ts index a320151f..3f6ff3f2 100644 --- a/server/harness/acp/session.test.ts +++ b/server/harness/acp/session.test.ts @@ -1,3 +1,4 @@ +import { splitContextAttachments } from '@/lib/moi-attachments' // Tests for the ACP session layer against a mock ACP agent (a tiny // newline-JSON-RPC script), exercising the REAL client transport + session // record + adapter — everything except a live backend. Covers the replay path @@ -375,3 +376,20 @@ describe('ACP model state', () => { expect(calls[3].params).toEqual({ sessionId: agent.sessionId, modelId: 'model-b' }) }) }) + +test('context-only sends reach the ACP prompt and retain display parts', async () => { + const agent = await mockAgent() + const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + await sendAcpMessage(agent.config, { + ...agent.ctx, + sessionId: agent.sessionId, + isNew: false, + content: '', + attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })) + }) + const prompt = (await agent.calls()).find(call => call.method === 'session/prompt')! + const params = prompt.params as { prompt: Array<{ type: string; text?: string }> } + expect( + splitContextAttachments(params.prompt.find(b => b.type === 'text')!.text!).attachments + ).toEqual(contextAttachments) +}) diff --git a/server/harness/acp/session.ts b/server/harness/acp/session.ts index 990e5d23..f7f6f0b9 100644 --- a/server/harness/acp/session.ts +++ b/server/harness/acp/session.ts @@ -1,3 +1,5 @@ +import { partitionMessageAttachments } from '@/lib/message-attachments' +import type { MessageAttachment } from '@/lib/types' // Per-(workspaceId, sessionId) live ACP session adapter. // // One agent process per workspace (see ./client.ts) serves every session in @@ -18,6 +20,8 @@ // - `session/prompt` is a long-running request that resolves at end of turn, // so its promise IS the turn's lifetime — activity is mirrored from it, // never derived by counting frames. +import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import type { ContextAttachment } from '@/lib/types' import { appendAttachmentNote } from '@/lib/attachment-note' import { type MoiContext, appendMoiContext, renderMoiContext } from '@/lib/moi-context' import { type Part, applyEvent, emptyViewState } from '@/lib/format' @@ -495,14 +499,17 @@ async function attachReplayDurations(rec: SessionRecord): Promise { async function buildPrompt( text: string, uploads: StoredUpload[], - supportsImages: boolean + supportsImages: boolean, + contextAttachments: readonly ContextAttachment[] = [] ): Promise<{ blocks: AcpPromptBlock[]; parts: Part[] }> { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } + parts.push(...contextAttachmentParts(contextAttachments)) if (text) parts.push({ type: 'text', text }) + text = appendContextAttachments(text, contextAttachments) const blocks: AcpPromptBlock[] = [] const files: { filename: string; path: string }[] = [] @@ -582,21 +589,21 @@ export async function sendAcpMessage( sessionId: string isNew: boolean content: string - attachments?: string[] + attachments?: MessageAttachment[] optimisticId?: string model?: string stream?: boolean context?: MoiContext } ): Promise { - const uploads = input.attachments?.length - ? resolveUploads(input.workspaceId, input.attachments) - : [] - if (!input.content && uploads.length === 0) return + const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const uploads = resolveUploads(input.workspaceId, uploadIds) + if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return const { blocks, parts } = await buildPrompt( input.content, uploads, - config.supportsImages !== false + config.supportsImages !== false, + contextAttachments ) if (blocks.length === 0) return diff --git a/server/harness/claude-code/adapter.ts b/server/harness/claude-code/adapter.ts index 61610583..da614f26 100644 --- a/server/harness/claude-code/adapter.ts +++ b/server/harness/claude-code/adapter.ts @@ -1,3 +1,4 @@ +import { splitContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' import type { AdapterEmit, Part, @@ -549,7 +550,9 @@ export class ClaudeAdapter { // SDK persists that appended text, so fold the note back into // file chips here — a reloaded bubble matches the live one // instead of leaking temp paths into it. - const split = splitAttachmentNote(text) + const attached = splitContextAttachments(text) + parts.push(...contextAttachmentParts(attached.attachments)) + const split = splitAttachmentNote(attached.text) // moi's own context envelope strips precisely (marker-guarded); // other embedded machinery — system reminders, hook output — // strips by the shared rules, keeping the typed text. diff --git a/server/harness/claude-code/session.test.ts b/server/harness/claude-code/session.test.ts index ff277a88..a034d479 100644 --- a/server/harness/claude-code/session.test.ts +++ b/server/harness/claude-code/session.test.ts @@ -1,3 +1,4 @@ +import { splitContextAttachments } from '@/lib/moi-attachments' import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import * as sdk from '@anthropic-ai/claude-agent-sdk' import type { Options, Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' @@ -678,3 +679,20 @@ describe('Claude session message queue', () => { expect(drivers).toHaveLength(2) }, 7000) }) + +test('context-only sends reach Claude and publish a labelled chip', async () => { + const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + await send({ + content: '', + attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })) + }) + const content = drivers[0]!.inputs[0].message.content + expect(typeof content).toBe('string') + expect(splitContextAttachments(content as string).attachments).toEqual(contextAttachments) + expect(frames).toContainEqual( + expect.objectContaining({ + kind: 'turn', + turn: expect.objectContaining({ parts: [{ type: 'context', ...contextAttachments[0] }] }) + }) + ) +}) diff --git a/server/harness/claude-code/session.ts b/server/harness/claude-code/session.ts index 0fa1e458..8e3f385c 100644 --- a/server/harness/claude-code/session.ts +++ b/server/harness/claude-code/session.ts @@ -1,3 +1,4 @@ +import { partitionMessageAttachments } from '@/lib/message-attachments' // One long-lived SDK query per session, with follow-ups queued in moi. // // Dispatch waits for the preceding user turn to finish before applying the next @@ -6,6 +7,8 @@ // // The SDK persists history to disk. After eviction or a server restart, the // next message resumes that history in a new process. +import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import type { ContextAttachment } from '@/lib/types' import { type Options, type Query, @@ -49,14 +52,16 @@ type MessageContent = SDKUserMessage['message']['content'] export function buildUserMessage( text: string, uploads: StoredUpload[], - displayText = text + contextAttachments: readonly ContextAttachment[] = [] ): { content: MessageContent; parts: Part[] } { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } - if (displayText) parts.push({ type: 'text', text: displayText }) + parts.push(...contextAttachmentParts(contextAttachments)) + if (text) parts.push({ type: 'text', text }) + text = appendContextAttachments(text, contextAttachments) if (uploads.length === 0) return { content: text, parts } @@ -712,11 +717,14 @@ function createLiveSession(input: { // turn starts. The returned promise settles when the message reaches the SDK. export async function sendCCMessage(input: SendMessageInput): Promise { // Expired uploads are omitted. Avoid starting a session if nothing remains. - const uploads = input.attachments?.length - ? resolveUploads(input.workspaceId, input.attachments) - : [] - if (!input.content && uploads.length === 0) return - const { content: userContent, parts } = buildUserMessage(input.content, uploads) + const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const uploads = resolveUploads(input.workspaceId, uploadIds) + if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return + const { content: userContent, parts } = buildUserMessage( + input.content, + uploads, + contextAttachments + ) // Keep context in its own block: the SDK skips tag-leading blocks when // extracting titles and previews, and the adapter strips them on replay. const content: MessageContent = input.context @@ -756,16 +764,18 @@ export async function sendCCMessage(input: SendMessageInput): Promise { timestamp: new Date().toISOString() } }) - const label = input.content || uploads.map(u => u.filename).join(', ') + const label = + input.content || + [...uploads.map(u => u.filename), ...contextAttachments.map(a => a.label)].join(', ') const completion = Promise.withResolvers() const accepted = new Promise((resolve, reject) => { messages.pending.push({ input, content, - titleSource: buildSessionTitleSource( - input.content, - uploads.map(u => u.filename) - ), + titleSource: buildSessionTitleSource(input.content, [ + ...uploads.map(u => u.filename), + ...contextAttachments.map(a => a.label) + ]), turnId, wireId: crypto.randomUUID(), label: label.replace(/\s+/g, ' ').slice(0, 120), diff --git a/server/harness/claude-code/sessions.ts b/server/harness/claude-code/sessions.ts index dafaa94e..13b4a654 100644 --- a/server/harness/claude-code/sessions.ts +++ b/server/harness/claude-code/sessions.ts @@ -1,5 +1,6 @@ // Session discovery + history replay for Claude Code workspaces, backed by // the Agent SDK's persisted `.jsonl` session files. +import { splitContextAttachments, stripContextAttachmentsLoose } from '@/lib/moi-attachments' import { getSessionInfo, getSessionMessages, @@ -35,12 +36,13 @@ export function claudeSessionSummary( session: Pick ): string { if (session.customTitle || !session.firstPrompt || session.summary !== session.firstPrompt) { - return session.summary + return stripContextAttachmentsLoose(session.summary) } - const split = splitAttachmentNote(session.firstPrompt) + const attached = splitContextAttachments(session.firstPrompt) + const split = splitAttachmentNote(attached.text) return isAttachmentOnlyPlaceholder(split.text) ? attachmentOnlyFilenames(split.text).join(', ') - : session.summary + : stripContextAttachmentsLoose(split.text) || attached.attachments.map(a => a.label).join(', ') } export function visibleClaudeSessions>(sessions: T[]): T[] { @@ -67,7 +69,7 @@ export function selectOldestSessionFirstUserMessage( (a.createdAt ?? a.lastModified) - (b.createdAt ?? b.lastModified) || a.sessionId.localeCompare(b.sessionId) )[0] - return oldest?.firstPrompt + return oldest?.firstPrompt ? stripContextAttachmentsLoose(oldest.firstPrompt) : undefined } export function selectLatestSessionUpdatedAt( diff --git a/server/harness/codex/adapter.ts b/server/harness/codex/adapter.ts index 8d93c8e2..95a681c1 100644 --- a/server/harness/codex/adapter.ts +++ b/server/harness/codex/adapter.ts @@ -1,6 +1,11 @@ // Pure Codex wire → display mapping. A rendered item becomes a Turn or notice; // tool items carry their native lifecycle through a tool-call part. The wire // types are a defensive subset; see NOTES.md for maintenance. +import { + splitContextAttachments, + contextAttachmentParts, + stripContextAttachmentsLoose +} from '@/lib/moi-attachments' import type { Part, StreamEvent, @@ -138,7 +143,7 @@ export function codexServiceTierForFastMode( // Legacy context can be cut mid-envelope in a thread/list preview. function cleanPreview(preview: string | undefined): string { - return preview ? stripMoiContextLoose(preview).trim() : '' + return preview ? stripContextAttachmentsLoose(stripMoiContextLoose(preview)).trim() : '' } export function codexThreadToSessionInfo(t: CodexThread): SessionInfo { @@ -284,7 +289,9 @@ function userInputToParts(content: CodexUserInput[] | undefined): Part[] { for (const c of content ?? []) { // Strip context from older sends that used the text-envelope fallback. if (c.type === 'text' && c.text) { - const text = stripMoiContext(c.text) + const attached = splitContextAttachments(stripMoiContext(c.text)) + parts.push(...contextAttachmentParts(attached.attachments)) + const text = attached.text if (text) parts.push({ type: 'text', text }) } else if (c.type === 'image' && c.url) parts.push({ type: 'file', mediaType: 'image/*', url: c.url }) diff --git a/server/harness/codex/session.test.ts b/server/harness/codex/session.test.ts index 8726b33a..88dceb6b 100644 --- a/server/harness/codex/session.test.ts +++ b/server/harness/codex/session.test.ts @@ -1,3 +1,4 @@ +import { splitContextAttachments } from '@/lib/moi-attachments' import { afterEach, describe, expect, spyOn, test } from 'bun:test' import type { BroadcastFrame, StreamEvent } from '@/lib/types' @@ -257,18 +258,27 @@ describe('Codex live session lifecycle', () => { mediaType: 'image/gif', bytes: Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64') }) - await f.send('inspect', { model: 'text', attachments: [upload.id] }) + await f.send('inspect', { + model: 'text', + attachments: [{ type: 'upload', uploadId: upload.id }] + }) expect(f.calls.some(call => call.method === 'turn/start')).toBe(false) expect(f.frames().at(-1)).toMatchObject({ kind: 'error', content: expect.stringContaining('does not accept images') }) await f.send('start text turn', { model: 'text' }) - await f.send('switch mid-turn', { model: 'vision', attachments: [upload.id] }) + await f.send('switch mid-turn', { + model: 'vision', + attachments: [{ type: 'upload', uploadId: upload.id }] + }) expect(f.calls.some(call => call.method === 'turn/steer')).toBe(false) expect(f.active()).toBe(true) f.emit('turn/completed', { turn: { id: 'turn-1', status: 'completed', items: [] } }) - await f.send('legacy image', { model: 'legacy', attachments: [upload.id] }) + await f.send('legacy image', { + model: 'legacy', + attachments: [{ type: 'upload', uploadId: upload.id }] + }) expect(f.calls.at(-1)).toMatchObject({ method: 'turn/start', params: { @@ -511,3 +521,24 @@ describe('Codex live session lifecycle', () => { expect(f.calls.filter(call => call.method === 'thread/resume')).toHaveLength(2) }) }) + +for (const native of [true, false]) { + test(`context-only sends are durable with native context ${native}`, async () => { + const f = fixture() + f.client.supportsAdditionalContext = native + const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + await f.send('', { + attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })), + optimisticId: 'context-turn', + context: { activeTab: 'view:orders' } + }) + const start = f.calls.find(call => call.method === 'turn/start')! + const sent = start.params.input as Array<{ type: string; text?: string }> + const raw = sent.find(item => item.type === 'text')!.text! + expect(splitContextAttachments(raw).attachments).toEqual(contextAttachments) + const event = f.events().find(event => event.kind === 'turn' && event.turn.role === 'user') + expect(event?.kind === 'turn' ? event.turn.parts : null).toEqual([ + { type: 'context', ...contextAttachments[0] } + ]) + }) +} diff --git a/server/harness/codex/session.ts b/server/harness/codex/session.ts index 891d3b1c..5c4373b1 100644 --- a/server/harness/codex/session.ts +++ b/server/harness/codex/session.ts @@ -1,6 +1,10 @@ +import { partitionMessageAttachments } from '@/lib/message-attachments' +import type { MessageAttachment } from '@/lib/types' // Live state per (workspaceId, sessionId): display history, native lifecycle, // and previews. client.ts owns the shared workspace process; // Codex owns durable history. See NOTES.md for ordering and recovery rules. +import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import type { ContextAttachment } from '@/lib/types' import { appendAttachmentNote } from '@/lib/attachment-note' import { buildSessionTitleSource } from '../session-title' import { @@ -889,14 +893,17 @@ async function resumeSession(input: ResumeInput): Promise { // retain the original attachment metadata for the user's bubble. async function buildUserInput( text: string, - uploads: StoredUpload[] + uploads: StoredUpload[], + contextAttachments: readonly ContextAttachment[] = [] ): Promise<{ input: CodexUserInputItem[]; parts: Part[] }> { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } + parts.push(...contextAttachmentParts(contextAttachments)) if (text) parts.push({ type: 'text', text }) + text = appendContextAttachments(text, contextAttachments) const input: CodexUserInputItem[] = [] for (const u of uploads) { @@ -921,7 +928,7 @@ type CodexSendInput = { sessionId: string isNew: boolean content: string - attachments?: string[] + attachments?: MessageAttachment[] optimisticId?: string model?: string effort?: string @@ -949,17 +956,20 @@ async function sendMessage( lane: SendLane, generation: number ): Promise { - const uploads = input.attachments?.length - ? resolveUploads(input.workspaceId, input.attachments) - : [] - if (!input.content && uploads.length === 0) return + const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const uploads = resolveUploads(input.workspaceId, uploadIds) + if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return const sessionTitleSource = input.isNew - ? buildSessionTitleSource( - input.content, - uploads.map(upload => upload.filename) - ) + ? buildSessionTitleSource(input.content, [ + ...uploads.map(upload => upload.filename), + ...contextAttachments.map(a => a.label) + ]) : undefined - const { input: userInput, parts } = await buildUserInput(input.content, uploads) + const { input: userInput, parts } = await buildUserInput( + input.content, + uploads, + contextAttachments + ) if (userInput.length === 0) return const serviceTier = codexServiceTierForFastMode(input.fastMode) diff --git a/server/harness/openclaw/adapter.ts b/server/harness/openclaw/adapter.ts index 57766501..0f2dfdd7 100644 --- a/server/harness/openclaw/adapter.ts +++ b/server/harness/openclaw/adapter.ts @@ -8,6 +8,11 @@ // // `toStreamEvents` is the cold path (REST replay); `messageToTurn` is the // per-frame path the live session uses. +import { + splitContextAttachments, + contextAttachmentParts, + stripContextAttachmentsLoose +} from '@/lib/moi-attachments' import type { Part, StreamEvent, ToolCall, ToolState, Turn, TurnMeta } from '@/lib/format' import type { SessionInfo } from '@/lib/types' @@ -28,7 +33,9 @@ import { stripSubagentEnvelope, stripUserMessageMetadata } from './strip' // every title source, not just the preview, so no title shows the envelope. // A no-op on channel/cron labels that never contain it. function cleanTitle(text: string | undefined): string { - return stripSubagentEnvelope(stripMoiContextLoose(text?.trim() ?? '')).trim() + return stripSubagentEnvelope( + stripContextAttachmentsLoose(stripMoiContextLoose(text?.trim() ?? '')) + ).trim() } export function toSessionInfo(row: OpenClawSessionRow, cwd: string): SessionInfo { @@ -225,7 +232,16 @@ export function messageToTurn( ? [{ type: 'text', text: msg.content }] : [] const parts = blocks - .map(b => blockToPart(b, msg.role, results, omitToolCallIds)) + .flatMap(b => { + if (msg.role === 'user' && b.type === 'text' && typeof b.text === 'string') { + const attached = splitContextAttachments(stripUserMessageMetadata(b.text)) + return [ + ...contextAttachmentParts(attached.attachments), + blockToPart({ ...b, text: attached.text }, msg.role, results, omitToolCallIds) + ] + } + return [blockToPart(b, msg.role, results, omitToolCallIds)] + }) .filter((p): p is Part => p !== null) if (parts.length === 0) return null const ocId = msg.__openclaw?.id diff --git a/server/harness/openclaw/live-frames.test.ts b/server/harness/openclaw/live-frames.test.ts index 37cdb427..2ff06e97 100644 --- a/server/harness/openclaw/live-frames.test.ts +++ b/server/harness/openclaw/live-frames.test.ts @@ -1,3 +1,4 @@ +import { appendContextAttachments } from '@/lib/moi-attachments' // Wire-fixture tests for the live protocol-v4 layer: chat delta previews, // session.tool result flattening, and durable-row → Turn mapping. Fixtures are // real frames captured from live gateways (2026.7.1 events-v2/events-tool runs, @@ -12,7 +13,7 @@ import { messageToTurn, toStreamEvents } from './adapter' -import { chatPreviewBlocks, claimPreviewSource, normalizeEchoText } from './session' +import { chatPreviewBlocks, claimPreviewSource, normalizeEchoText, userEchoKey } from './session' describe('chatPreviewBlocks', () => { test('maps a real first chat delta onto one text block', () => { @@ -315,3 +316,34 @@ describe('claimPreviewSource', () => { expect(claimPreviewSource(rec, 'r2', 'agent')).toBe(false) }) }) + +test('context-only echo keys are non-empty and distinguish attached records', () => { + const first = { source: 'view:orders', label: 'Order', context: { id: '1' } } + const wire = appendContextAttachments('', [first]) + expect(normalizeEchoText(wire)).not.toBe('') + expect(normalizeEchoText(wire)).toBe( + normalizeEchoText( + appendContextAttachments('', [ + { context: first.context, label: first.label, source: first.source } + ]) + ) + ) + expect(normalizeEchoText(wire)).not.toBe( + normalizeEchoText(appendContextAttachments('', [{ ...first, context: { id: '2' } }])) + ) +}) + +test('parsed user turns match serialized echo keys with and without message text', () => { + const attachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + for (const text of ['', 'Review this\norder']) { + const wire = appendContextAttachments(text, attachments) + const turn = messageToTurn({ role: 'user', content: wire }, 'test-session', 0, new Map()) + expect(turn).not.toBeNull() + expect( + userEchoKey( + turn!.parts.find(p => p.type === 'text')?.text ?? '', + turn!.parts.filter(p => p.type === 'context') + ) + ).toBe(normalizeEchoText(wire)) + } +}) diff --git a/server/harness/openclaw/session.ts b/server/harness/openclaw/session.ts index b0c618d2..d8440dfc 100644 --- a/server/harness/openclaw/session.ts +++ b/server/harness/openclaw/session.ts @@ -1,3 +1,5 @@ +import { partitionMessageAttachments } from '@/lib/message-attachments' +import type { ContextAttachment, MessageAttachment } from '@/lib/types' // Per-(workspaceId, sessionId) live OpenClaw session. // // Holds the in-memory view for one session and keeps it current from the @@ -10,6 +12,11 @@ // The frame families and the order they arrive in are documented in NOTES.md // §6; the rules that follow from it (who owns a tool card, why identity is // decided once) are worth reading before changing anything here. +import { + appendContextAttachments, + splitContextAttachments, + contextAttachmentParts +} from '@/lib/moi-attachments' import { appendAttachmentNote } from '@/lib/attachment-note' import { type MoiContext, @@ -490,7 +497,17 @@ function previewMessageId(sessionKey: string, runId: string): string { // between what we sent and what the gateway stored can't defeat the match. export function normalizeEchoText(text: string | undefined): string { if (typeof text !== 'string') return '' - return stripMoiContext(text).replace(/\s+/g, ' ').trim() + const attached = splitContextAttachments(stripMoiContext(text)) + return userEchoKey(attached.text, attached.attachments) +} + +export function userEchoKey(text: string, attachments: readonly ContextAttachment[] = []): string { + const visible = text.replace(/\s+/g, ' ').trim() + // Include attachments in the fallback key: context-only messages have no + // visible text, and two identical prompts may refer to different records. + return attachments.length + ? `${visible}\n${JSON.stringify(contextAttachmentParts(attachments))}` + : visible } // Synthetic turns (live tool cards, live thinking spans) have no transcript @@ -526,7 +543,10 @@ function emitTurn(rec: SessionRecord, msg: OpenClawMessage, idx: number): void { ) : -1 if (at < 0) { - const text = normalizeEchoText(turn.parts.find(p => p.type === 'text')?.text) + const text = userEchoKey( + turn.parts.find(p => p.type === 'text')?.text ?? '', + turn.parts.filter(p => p.type === 'context') + ) if (text) at = rec.pendingUserEchoes.findIndex(e => normalizeEchoText(e.text) === text) } if (at >= 0) { @@ -1216,7 +1236,7 @@ export async function sendOpenClawMessage(input: { // message, so we materialize each upload to a temp file and append the paths // for the agent to read. Rich vision blocks await a gateway content-block API // (see dev/file-uploads.md). - attachments?: string[] + attachments?: MessageAttachment[] optimisticId?: string // Picker selections, applied to the gateway session via `sessions.patch` // before the send (see applySessionSettings). @@ -1230,17 +1250,16 @@ export async function sendOpenClawMessage(input: { context?: MoiContext }): Promise { // Fold any attachments into the message text as file-path references. - const uploads = input.attachments?.length - ? resolveUploads(input.workspaceId, input.attachments) - : [] - let content = input.content + const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const uploads = resolveUploads(input.workspaceId, uploadIds) + let content = appendContextAttachments(input.content, contextAttachments) if (uploads.length > 0) { const files: { filename: string; path: string }[] = [] for (const u of uploads) { const p = await materializeToPath(u) if (p) files.push({ filename: u.filename, path: p }) } - content = appendAttachmentNote(input.content, files) + content = appendAttachmentNote(content, files) } // Attachment-only send whose ids all expired → nothing to say; don't open a // session for an empty message. diff --git a/server/test/__fixtures__/with-chatcontext.tsx b/server/test/__fixtures__/with-chatcontext.tsx new file mode 100644 index 00000000..37845170 --- /dev/null +++ b/server/test/__fixtures__/with-chatcontext.tsx @@ -0,0 +1,5 @@ +import { addChatContext } from 'moi' + +export default function ContextButton() { + return +} diff --git a/server/test/__fixtures__/with-sendchatmessage.tsx b/server/test/__fixtures__/with-sendchatmessage.tsx index 1a708ce6..aa456cec 100644 --- a/server/test/__fixtures__/with-sendchatmessage.tsx +++ b/server/test/__fixtures__/with-sendchatmessage.tsx @@ -2,6 +2,6 @@ import { sendChatMessage } from 'moi' export const config = { title: 'Chase' } export default function WithChat() { return ( - + ) } diff --git a/server/test/build-applet.test.ts b/server/test/build-applet.test.ts index 5620a3f4..6573ad50 100644 --- a/server/test/build-applet.test.ts +++ b/server/test/build-applet.test.ts @@ -528,7 +528,13 @@ describe('moi fileUrl module', () => { // Two args only: the applet cannot pass a source, because attribution is // stamped host-side (applet-runtime.ts) from the identity the bridge was // attached with. - expect(result.js).toContain('sendChatMessage(message, context)') + expect(result.js).toContain('sendChatMessage(input, context)') + }) + + test('bundles addChatContext through the applet bridge', async () => { + const result = await buildApplet(join(FIXTURES, 'with-chatcontext.tsx'), undefined, 'view') + expect(result.js).toContain('function addChatContext') + expect(result.js).toContain('addChatContext(input)') }) test('every bundle entry exports the bridge wiring, even without a moi import', async () => { diff --git a/server/test/context-attachment-replay.test.ts b/server/test/context-attachment-replay.test.ts new file mode 100644 index 00000000..4a0fc204 --- /dev/null +++ b/server/test/context-attachment-replay.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import { appendMoiContext, renderMoiContext, moiContextSystemReminder } from '@/lib/moi-context' +import { ClaudeAdapter } from '../harness/claude-code/adapter' +import { codexItemToTurn } from '../harness/codex/adapter' +import { messageToTurn } from '../harness/openclaw/adapter' +import { replayedUserParts } from '../harness/acp/adapter' +import { buildUserMessage } from '../harness/claude-code/session' + +const attachments = [{ source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } }] +const ambient = renderMoiContext({ activeTab: 'view:orders' }) + +describe('durable context attachments', () => { + for (const text of ['Review this order', '']) { + test(`replays the same display parts across all harnesses: ${text || 'context only'}`, () => { + const raw = appendContextAttachments(text, attachments) + const expected = [ + ...contextAttachmentParts(attachments), + ...(text ? [{ type: 'text' as const, text }] : []) + ] + const cc = new ClaudeAdapter() + cc.ingest({ + type: 'user', + uuid: 'user', + message: { + role: 'user', + content: [ + { type: 'text', text: moiContextSystemReminder(ambient) }, + { type: 'text', text: raw } + ] + } + }) + const event = cc.hello().find(e => e.kind === 'turn') + expect(event?.kind === 'turn' ? event.turn.parts : null).toEqual(expected) + expect(event?.kind === 'turn' ? event.turn.origin : null).toEqual({ kind: 'user-input' }) + for (const content of [raw, appendMoiContext(raw, ambient)]) { + expect( + codexItemToTurn( + { type: 'userMessage', id: 'user', content: [{ type: 'text', text: content }] }, + 'session' + )?.parts + ).toEqual(expected) + expect(messageToTurn({ role: 'user', content }, 'session', 0, new Map())?.parts).toEqual( + expected + ) + expect(replayedUserParts(content)).toEqual(expected) + } + const sent = buildUserMessage(text, [], attachments) + expect(sent.parts).toEqual(expected) + expect(sent.content).toBe(raw) + }) + } +}) diff --git a/server/test/skills-template.test.ts b/server/test/skills-template.test.ts index 7fbd29e1..ba92b081 100644 --- a/server/test/skills-template.test.ts +++ b/server/test/skills-template.test.ts @@ -25,6 +25,11 @@ describe('installBundledSkills', () => { const skillMd = await Bun.file(join(dir, SKILL_MD)).text() expect(skillMd).toContain('Standard UI components') + expect(skillMd).toContain('references/INTENTS.md') + const intents = await Bun.file(join(dir, 'moi-workspace', 'references', 'INTENTS.md')).text() + expect(intents).toContain('function addChatContext(input:') + expect(intents).toContain('function sendChatMessage(input:') + expect(intents).toContain('function focusTab(tab:') expect(skillMd).toContain('moi ui-components add') expect(skillMd).toContain('## Customizing workspace appearance') expect(skillMd).toContain('moi theme --font= --color=') From 5a08e052e06a96d1b742e7b0fbae8ec9071c8fc8 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 15:33:51 +0200 Subject: [PATCH 10/24] Preserve staged context across new chat session IDs --- client/features/chat/chat-store.ts | 17 ++++++++------- .../attachments/context-attachments.test.ts | 21 +++++++++++++++++++ client/features/chat/useChat.ts | 4 ++++ lib/moi-attachments.test.ts | 14 +++++++++++++ lib/moi-attachments.ts | 4 +++- server/test/context-attachment-replay.test.ts | 2 +- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/client/features/chat/chat-store.ts b/client/features/chat/chat-store.ts index d81a92e4..ee99164b 100644 --- a/client/features/chat/chat-store.ts +++ b/client/features/chat/chat-store.ts @@ -107,7 +107,8 @@ export type LiveStore = { ) => void removeAttachment: (workspaceId: string, sessionId: string | null, localId: string) => void clearAttachments: (workspaceId: string, sessionId: string | null) => void - renameSession: (workspaceId: string, from: string, to: string) => void + // `null` assigns the new-chat draft its first session id. + renameSession: (workspaceId: string, from: string | null, to: string) => void // Upsert a preview snapshot (last write wins — blocks are cumulative). setPreview: (frame: Omit) => void @@ -240,7 +241,7 @@ export const liveStore = createStore()(set => ({ renameSession: (workspaceId, from, to) => set(s => { - const fromKey = key(workspaceId, from) + const fromKey = attachmentKey(workspaceId, from) const toKey = key(workspaceId, to) const activity = { ...s.activity } const errors = { ...s.errors } @@ -265,15 +266,15 @@ export const liveStore = createStore()(set => ({ previews[id] = { ...p, sessionId: to } } } - // Context has no in-flight upload callbacks bound to the old id. - // Move staged context when the provider assigns the chat its real id. + // Context can follow session id changes immediately. Upload callbacks + // still target their original session id, so leave uploads in place. let attachments = s.attachments - const contexts = s.attachments[fromKey]?.filter(a => a.kind === 'context') ?? [] + const contexts = attachments[fromKey]?.filter(a => a.kind === 'context') ?? [] if (fromKey !== toKey && contexts.length) { attachments = { - ...s.attachments, - [fromKey]: s.attachments[fromKey].filter(a => a.kind !== 'context'), - [toKey]: [...(s.attachments[toKey] ?? []), ...contexts] + ...attachments, + [fromKey]: attachments[fromKey].filter(a => a.kind !== 'context'), + [toKey]: [...(attachments[toKey] ?? []), ...contexts] } } return { activity, errors, previews, attachments } diff --git a/client/features/chat/composer/attachments/context-attachments.test.ts b/client/features/chat/composer/attachments/context-attachments.test.ts index 92893b52..c01e8cf2 100644 --- a/client/features/chat/composer/attachments/context-attachments.test.ts +++ b/client/features/chat/composer/attachments/context-attachments.test.ts @@ -56,6 +56,27 @@ describe('context staging and sends', () => { expect(attachmentsForSend(workspaceId, 'renamed')).toHaveLength(1) }) + test('an immediate send keeps new-chat context staged through both session id changes', () => { + stageChatContext(target, attachment) + stageChatContext(target, { ...attachment, label: 'Another order' }) + stageChatContext({ workspaceId, sessionId: 'other' }, { ...attachment, label: 'Other chat' }) + const staged = attachmentsForSend(workspaceId, null) + const options = { applet: { source: 'view:orders' } } + + expect(attachmentsForSend(workspaceId, null, options)).toEqual([]) + liveStore.getState().renameSession(workspaceId, null, 'temporary') + expect(attachmentsForSend(workspaceId, null)).toEqual([]) + expect(attachmentsForSend(workspaceId, 'temporary')).toEqual(staged) + expect(attachmentsForSend(workspaceId, 'temporary', options)).toEqual([]) + + liveStore.getState().renameSession(workspaceId, 'temporary', 'real') + expect(attachmentsForSend(workspaceId, 'temporary')).toEqual([]) + expect(attachmentsForSend(workspaceId, 'real')).toEqual(staged) + liveStore.getState().clearAttachments(workspaceId, 'real') + expect(attachmentsForSend(workspaceId, 'real')).toEqual([]) + expect(attachmentsForSend(workspaceId, 'other')[0].name).toBe('Other chat') + }) + test('sends context with uploads without changing annotation image positions', () => { stageChatContext(target, attachment) liveStore.getState().addAttachments(workspaceId, null, [ diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 6dbbe0b5..759d0225 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -118,6 +118,10 @@ export function useChat(address: WorkspaceTabAddress) { if (!sid) { sid = crypto.randomUUID() isNew = true + // An immediate applet send leaves the user's context staged in this chat. + if (!ownsComposerAttachments(options)) { + liveStore.getState().renameSession(workspaceId, selectedSessionId, sid) + } selectSession(sid) startOptimisticSession({ queryClient: qc, diff --git a/lib/moi-attachments.test.ts b/lib/moi-attachments.test.ts index 89f79641..194c0424 100644 --- a/lib/moi-attachments.test.ts +++ b/lib/moi-attachments.test.ts @@ -78,6 +78,20 @@ describe('context attachments', () => { } }) + test('a literal or malformed opening tag does not hide a later attachment block', () => { + for (const text of [ + 'What does mean?', + 'broken', + 'broken', + '{"version":2,"attachments":[]}' + ]) { + expect(splitContextAttachments(appendContextAttachments(text, [attachment]))).toEqual({ + text, + attachments: [attachment] + }) + } + }) + test('coexists with file notes and hides metadata from truncated previews', () => { const files = [{ filename: 'notes.txt', path: '/tmp/notes.txt' }] const wire = appendAttachmentNote(appendContextAttachments('Review', [attachment]), files) diff --git a/lib/moi-attachments.ts b/lib/moi-attachments.ts index bd5c207e..3ab0abe0 100644 --- a/lib/moi-attachments.ts +++ b/lib/moi-attachments.ts @@ -87,8 +87,10 @@ export function splitContextAttachments(text: string): { attachments: ContextAttachment[] } { const attachments: ContextAttachment[] = [] + // Serialized JSON escapes '<', so a candidate cannot span another opening + // tag. A literal tag in the user's text must not swallow the saved block. const cleaned = text.replace( - /\s*([\s\S]*?)\s*<\/moi-attachments>/g, + /([^<]*)<\/moi-attachments>/g, (block: string, json: string) => { try { const parsed: unknown = JSON.parse(json) diff --git a/server/test/context-attachment-replay.test.ts b/server/test/context-attachment-replay.test.ts index 4a0fc204..90388e29 100644 --- a/server/test/context-attachment-replay.test.ts +++ b/server/test/context-attachment-replay.test.ts @@ -11,7 +11,7 @@ const attachments = [{ source: 'view:orders', label: 'Order #1042', context: { o const ambient = renderMoiContext({ activeTab: 'view:orders' }) describe('durable context attachments', () => { - for (const text of ['Review this order', '']) { + for (const text of ['Review this order', '', 'What does mean?']) { test(`replays the same display parts across all harnesses: ${text || 'context only'}`, () => { const raw = appendContextAttachments(text, attachments) const expected = [ From 1dfb540f12bc513e77d557c05a79938cbf3a9bf2 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 16:40:59 +0200 Subject: [PATCH 11/24] Improve workspace UI and applet tooling --- .../features/applets/applet-runtime.test.ts | 58 +++++++ client/features/applets/applet-runtime.ts | 46 +++-- client/features/chat/ChatPanel.tsx | 2 +- client/features/chat/applet-chat-intents.ts | 10 +- client/features/chat/chat-send.ts | 14 +- client/features/chat/chat-store.ts | 44 +++-- .../features/chat/composer/ChatComposer.tsx | 14 +- ...achmentChip.tsx => TextAttachmentChip.tsx} | 4 +- .../attachments/draft-attachments.test.ts | 164 +++++++++++++++++- .../composer/attachments/draft-attachments.ts | 128 ++++++++------ ...ments.test.ts => text-attachments.test.ts} | 55 +++--- .../chat/composer/attachments/types.ts | 4 +- .../chat/composer/attachments/uploads.ts | 10 ++ client/features/chat/messages/TurnView.tsx | 18 +- client/features/chat/useChat.ts | 2 +- client/features/dev/chat-states-fixtures.ts | 8 +- client/features/drawings/useChatAnnotation.ts | 8 +- .../features/drawings/useViewBuilderSketch.ts | 8 +- client/features/workspace/WorkspaceScreen.tsx | 4 +- dev/file-uploads.md | 2 +- lib/chat-title.ts | 4 +- lib/format.ts | 6 +- lib/message-attachments.test.ts | 14 +- lib/message-attachments.ts | 32 ++-- lib/moi-attachments.test.ts | 81 ++++----- lib/moi-attachments.ts | 90 ++++------ lib/types.ts | 10 +- server/api-upload-path.test.ts | 99 +++++++++++ server/api-view-builders.test.ts | 2 +- server/api.ts | 21 ++- server/applets/build-applet.ts | 4 +- server/harness/acp/adapter.test.ts | 2 +- server/harness/acp/adapter.ts | 14 +- server/harness/acp/session.test.ts | 14 +- server/harness/acp/session.ts | 18 +- server/harness/claude-code/NOTES.md | 2 +- server/harness/claude-code/adapter.ts | 12 +- server/harness/claude-code/session.test.ts | 12 +- server/harness/claude-code/session.ts | 24 ++- server/harness/claude-code/sessions.ts | 10 +- server/harness/codex/adapter.ts | 14 +- server/harness/codex/session.test.ts | 12 +- server/harness/codex/session.ts | 22 +-- server/harness/openclaw/adapter.ts | 12 +- server/harness/openclaw/live-frames.test.ts | 20 +-- server/harness/openclaw/session.ts | 22 +-- server/moi-scaffold.ts | 8 +- .../test/__fixtures__/with-chatattachment.tsx | 5 + server/test/__fixtures__/with-chatcontext.tsx | 5 - server/test/attachments.test.ts | 10 +- server/test/build-applet.test.ts | 8 +- server/test/skills-template.test.ts | 2 +- ...test.ts => text-attachment-replay.test.ts} | 12 +- server/uploads.ts | Bin 9296 -> 11134 bytes .../moi-workspace/references/INTENTS.md | 62 ++++--- 55 files changed, 836 insertions(+), 452 deletions(-) rename client/features/chat/composer/attachments/{ContextAttachmentChip.tsx => TextAttachmentChip.tsx} (64%) rename client/features/chat/composer/attachments/{context-attachments.test.ts => text-attachments.test.ts} (69%) create mode 100644 server/api-upload-path.test.ts create mode 100644 server/test/__fixtures__/with-chatattachment.tsx delete mode 100644 server/test/__fixtures__/with-chatcontext.tsx rename server/test/{context-attachment-replay.test.ts => text-attachment-replay.test.ts} (86%) diff --git a/client/features/applets/applet-runtime.test.ts b/client/features/applets/applet-runtime.test.ts index 367b2c0e..9251ac36 100644 --- a/client/features/applets/applet-runtime.test.ts +++ b/client/features/applets/applet-runtime.test.ts @@ -338,3 +338,61 @@ describe('workspace isolation', () => { expect(b.calls).toEqual([]) }) }) + +describe('addChatAttachment', () => { + test('accepts text and both file sources, stamps source, and ignores disposed bridges', () => { + const ws = `ws-${crypto.randomUUID()}` + const received: unknown[] = [] + const runtime = appletRuntime(ws) + const off = runtime.on('addChatAttachment', value => received.push(value)) + const { bridge, dispose } = runtime.connect(VIEW) + const file = new File(['hello'], 'notes.txt') + bridge.addChatAttachment({ + type: 'text', + label: ' Notes ', + text: ' hello\n', + source: 'forged' + }) + bridge.addChatAttachment({ type: 'file', file }) + const pathInput = { type: 'file', path: 'reports/september.pdf' } + bridge.addChatAttachment(pathInput) + pathInput.path = 'changed.pdf' + dispose() + bridge.addChatAttachment({ type: 'file', file }) + expect(received).toEqual([ + { type: 'text', label: 'Notes', text: ' hello\n', source: 'view:board' }, + { type: 'file', file, source: 'view:board' }, + { type: 'file', path: 'reports/september.pdf', source: 'view:board' } + ]) + off() + }) + + test('rejects invalid text, file shapes, sizes and unsafe paths without emitting', () => { + const ws = `ws-${crypto.randomUUID()}` + const received: unknown[] = [] + const runtime = appletRuntime(ws) + const off = runtime.on('addChatAttachment', value => received.push(value)) + const { bridge } = runtime.connect(VIEW) + const file = new File(['hello'], 'notes.txt') + const oversized = new File([new Uint8Array(32 * 1024 * 1024 + 1)], 'large.bin') + for (const input of [ + null, + {}, + { type: 'text', label: '', text: 'hello' }, + { type: 'text', label: 'Notes', text: ' ' }, + { type: 'text', label: 'Notes', text: 'x'.repeat(5001) }, + { type: 'file' }, + { type: 'file', file: {} }, + { type: 'file', file: new Blob(['x']) }, + { type: 'file', file, path: 'notes.txt' }, + { type: 'file', file: oversized }, + ...['', '/etc/passwd', '../outside.txt', '.env', 'a/.hidden/b', 'C:\\file.txt'].map(path => ({ + type: 'file', + path + })) + ]) + bridge.addChatAttachment(input) + expect(received).toEqual([]) + off() + }) +}) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index 5927a440..46a3b20b 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -14,8 +14,9 @@ // — no central handlers object assembled by the screen. Applet → host only; // if a host → applet direction is ever added (`moi.on(...)`), `dispose` must // also unbind those listeners or a disposed module leaks. -import { snapshotChatContext } from '@/lib/moi-attachments' -import type { ContextAttachment } from '@/lib/types' +import { snapshotTextAttachment } from '@/lib/moi-attachments' +import type { ChatAttachmentInput } from '@/lib/types' +import { isWorkspaceAttachmentPath, MAX_UPLOAD_BYTES } from '@/lib/message-attachments' import { useEffect } from 'react' import { createNanoEvents } from 'nanoevents' @@ -41,9 +42,11 @@ export type AppletChatMessage = { context?: Record } +export type AppletChatAttachment = ChatAttachmentInput & { source: string } + // Events a workspace runtime emits — already validated, typed for host code. export type AppletEvents = { - addChatContext: (attachment: ContextAttachment) => void + addChatAttachment: (attachment: AppletChatAttachment) => void // Client-local replace-navigation to a workspace tab. `params` reach the // target view as its `params` prop via navigation state — JSON-plain only // (history state is structured-cloned). @@ -57,7 +60,7 @@ export type AppletEvents = { // cross the trust boundary from agent-authored code, and the runtime narrows // them before emitting. export type AppletBridge = { - addChatContext: (input: unknown) => void + addChatAttachment: (input: unknown) => void focusTab: (tab: unknown, params?: unknown) => void sendChatMessage: (input: unknown, context?: unknown) => void } @@ -128,17 +131,34 @@ function createRuntime(workspaceId: string) { let alive = true const source = appletSource(identity) const bridge: AppletBridge = { - addChatContext(input) { + addChatAttachment(input) { if (!alive) return - const snapshot = snapshotChatContext(input) - if (!snapshot) { - drop( - identity, - 'addChatContext() was dropped: provide a non-empty label (max 120 characters) and a plain JSON context object (max 5000 characters).' - ) - return + if (isParamsRecord(input)) { + if (input.type === 'text') { + const snapshot = snapshotTextAttachment(input) + if (snapshot) { + emitter.emit('addChatAttachment', { type: 'text', ...snapshot, source }) + return + } + } else if (input.type === 'file') { + if ( + input.file instanceof File && + input.path === undefined && + input.file.size <= MAX_UPLOAD_BYTES + ) { + emitter.emit('addChatAttachment', { type: 'file', file: input.file, source }) + return + } + if (isWorkspaceAttachmentPath(input.path) && input.file === undefined) { + emitter.emit('addChatAttachment', { type: 'file', path: input.path, source }) + return + } + } } - emitter.emit('addChatContext', { ...snapshot, source }) + drop( + identity, + 'addChatAttachment() was dropped: provide labelled text (label max 120 characters, text max 5000), or exactly one File (max 32 MB) or workspace-relative path. Hidden paths are not allowed.' + ) }, focusTab(tab, params) { if (!alive) return diff --git a/client/features/chat/ChatPanel.tsx b/client/features/chat/ChatPanel.tsx index 693f7fbc..3ab20820 100644 --- a/client/features/chat/ChatPanel.tsx +++ b/client/features/chat/ChatPanel.tsx @@ -105,7 +105,7 @@ export function ChatPanel({ ) const attachmentsUploading = useLive(state => (state.attachments[attachmentKey(workspaceId, effectiveSessionId)] ?? []).some( - attachment => attachment.kind !== 'context' && attachment.status === 'uploading' + attachment => attachment.kind !== 'text' && attachment.status === 'uploading' ) ) const promptDisabled = !canSubmitComposerAction(true, attachmentsUploading, agentAvailability) diff --git a/client/features/chat/applet-chat-intents.ts b/client/features/chat/applet-chat-intents.ts index d7ba50a1..4d6d8a89 100644 --- a/client/features/chat/applet-chat-intents.ts +++ b/client/features/chat/applet-chat-intents.ts @@ -1,8 +1,8 @@ -// Connect validated applet intents to the active chat: stage context in the +// Connect validated applet intents to the active chat: stage attachments in the // draft or send a message immediately, then reveal and focus chat. // The runtime owns argument validation, source attribution, and send rate limits; // these hooks own draft staging and agent availability checks for immediate sends. -import { stageChatContext } from './composer/attachments/draft-attachments' +import { stageChatAttachment } from './composer/attachments/draft-attachments' import { reportAppletError } from '@/client/features/applets/applet-log' import { type AppletChatMessage, useAppletEvent } from '@/client/features/applets/applet-runtime' import type { ChatSendOptions } from '@/client/features/chat/chat-send' @@ -60,10 +60,10 @@ export function useAppletChatMessage({ }) } -export function useAppletChatContext(sessionId: string | null, revealChat: () => void): void { +export function useAppletChatAttachment(sessionId: string | null, revealChat: () => void): void { const workspaceId = useWorkspaceId() - useAppletEvent(workspaceId, 'addChatContext', attachment => { - stageChatContext({ workspaceId, sessionId }, attachment) + useAppletEvent(workspaceId, 'addChatAttachment', attachment => { + void stageChatAttachment({ workspaceId, sessionId }, attachment) revealChat() }) } diff --git a/client/features/chat/chat-send.ts b/client/features/chat/chat-send.ts index 878bb7c2..1dee888d 100644 --- a/client/features/chat/chat-send.ts +++ b/client/features/chat/chat-send.ts @@ -1,5 +1,5 @@ import type { MessageAttachment } from '@/lib/types' -import { contextAttachmentParts } from '@/lib/moi-attachments' +import { textAttachmentParts } from '@/lib/moi-attachments' import type { QueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' @@ -33,22 +33,22 @@ export function attachmentsForSend( ): ChatAttachment[] { if (!ownsComposerAttachments(options)) return [] const pending = liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] ?? [] - return pending.filter(a => a.kind === 'context' || (a.status === 'ready' && a.upload)) + return pending.filter(a => a.kind === 'text' || (a.status === 'ready' && a.upload)) } export function attachmentPartsForOptimisticTurn(attachments: readonly ChatAttachment[]): Part[] { return attachments.map(attachment => { - if (attachment.kind === 'context') return contextAttachmentParts([attachment.attachment])[0] + if (attachment.kind === 'text') return textAttachmentParts([attachment.attachment])[0] if (attachment.upload?.kind === 'image' && attachment.previewUrl) { return { - type: 'file', + type: 'file-attachment', mediaType: attachment.mediaType, url: attachment.previewUrl, filename: attachment.name } } return { - type: 'file', + type: 'file-attachment', mediaType: attachment.mediaType, url: '', filename: attachment.name @@ -65,7 +65,7 @@ export function withAttachmentDirectives( const sketches: string[] = [] let imagePosition = 0 for (const attachment of attachments) { - if (attachment.kind === 'context' || attachment.upload?.kind !== 'image') continue + if (attachment.kind === 'text' || attachment.upload?.kind !== 'image') continue imagePosition += 1 if (attachment.kind === 'drawing' && attachment.purpose === 'annotation') { annotations.push(`${imagePosition}. ${JSON.stringify(attachment.sourceTab)}`) @@ -189,7 +189,7 @@ export function messageAttachmentsForSend( attachments: readonly ChatAttachment[] ): MessageAttachment[] { return attachments.flatMap(attachment => { - if (attachment.kind === 'context') return [{ type: 'context', ...attachment.attachment }] + if (attachment.kind === 'text') return [{ type: 'text', ...attachment.attachment }] return attachment.upload ? [{ type: 'upload', uploadId: attachment.upload.id }] : [] }) } diff --git a/client/features/chat/chat-store.ts b/client/features/chat/chat-store.ts index ee99164b..71208dd8 100644 --- a/client/features/chat/chat-store.ts +++ b/client/features/chat/chat-store.ts @@ -99,13 +99,8 @@ export type LiveStore = { ) => void setError: (workspaceId: string, sessionId: string, message: string | null) => void addAttachments: (workspaceId: string, sessionId: string | null, items: ChatAttachment[]) => void - updateAttachment: ( - workspaceId: string, - sessionId: string | null, - localId: string, - patch: ChatAttachmentPatch - ) => void - removeAttachment: (workspaceId: string, sessionId: string | null, localId: string) => void + updateAttachment: (workspaceId: string, localId: string, patch: ChatAttachmentPatch) => void + removeAttachment: (workspaceId: string, localId: string) => void clearAttachments: (workspaceId: string, sessionId: string | null) => void // `null` assigns the new-chat draft its first session id. renameSession: (workspaceId: string, from: string | null, to: string) => void @@ -192,15 +187,18 @@ export const liveStore = createStore()(set => ({ return { attachments: { ...s.attachments, [k]: [...(s.attachments[k] ?? []), ...items] } } }), - updateAttachment: (workspaceId, sessionId, localId, patch) => + updateAttachment: (workspaceId, localId, patch) => set(s => { - const k = attachmentKey(workspaceId, sessionId) + const k = Object.keys(s.attachments).find( + k => k.startsWith(`${workspaceId}:`) && s.attachments[k].some(a => a.localId === localId) + ) + if (!k) return {} const list = s.attachments[k] if (!list) return {} const target = list.find(attachment => attachment.localId === localId) if ( target && - target.kind !== 'context' && + target.kind !== 'text' && target.previewUrl && 'previewUrl' in patch && patch.previewUrl !== target.previewUrl @@ -210,20 +208,21 @@ export const liveStore = createStore()(set => ({ return { attachments: { ...s.attachments, - [k]: list.map(a => - a.localId === localId && a.kind !== 'context' ? { ...a, ...patch } : a - ) + [k]: list.map(a => (a.localId === localId && a.kind !== 'text' ? { ...a, ...patch } : a)) } } }), - removeAttachment: (workspaceId, sessionId, localId) => + removeAttachment: (workspaceId, localId) => set(s => { - const k = attachmentKey(workspaceId, sessionId) + const k = Object.keys(s.attachments).find( + k => k.startsWith(`${workspaceId}:`) && s.attachments[k].some(a => a.localId === localId) + ) + if (!k) return {} const list = s.attachments[k] if (!list) return {} const target = list.find(a => a.localId === localId) - if (target && target.kind !== 'context' && target.previewUrl) + if (target && target.kind !== 'text' && target.previewUrl) URL.revokeObjectURL(target.previewUrl) return { attachments: { ...s.attachments, [k]: list.filter(a => a.localId !== localId) } } }), @@ -232,7 +231,7 @@ export const liveStore = createStore()(set => ({ set(s => { const k = attachmentKey(workspaceId, sessionId) for (const a of s.attachments[k] ?? []) { - if (a.kind !== 'context' && a.previewUrl) URL.revokeObjectURL(a.previewUrl) + if (a.kind !== 'text' && a.previewUrl) URL.revokeObjectURL(a.previewUrl) } const next = { ...s.attachments } delete next[k] @@ -266,16 +265,15 @@ export const liveStore = createStore()(set => ({ previews[id] = { ...p, sessionId: to } } } - // Context can follow session id changes immediately. Upload callbacks - // still target their original session id, so leave uploads in place. + // Upload completions use stable attachment IDs, so every draft item can + // follow the chat through both temporary and provider session IDs. let attachments = s.attachments - const contexts = attachments[fromKey]?.filter(a => a.kind === 'context') ?? [] - if (fromKey !== toKey && contexts.length) { + if (fromKey !== toKey && attachments[fromKey]) { attachments = { ...attachments, - [fromKey]: attachments[fromKey].filter(a => a.kind !== 'context'), - [toKey]: [...(attachments[toKey] ?? []), ...contexts] + [toKey]: [...(attachments[toKey] ?? []), ...attachments[fromKey]] } + delete attachments[fromKey] } return { activity, errors, previews, attachments } }) diff --git a/client/features/chat/composer/ChatComposer.tsx b/client/features/chat/composer/ChatComposer.tsx index aab55061..1e965ced 100644 --- a/client/features/chat/composer/ChatComposer.tsx +++ b/client/features/chat/composer/ChatComposer.tsx @@ -1,4 +1,4 @@ -import { ContextAttachmentChip } from './attachments/ContextAttachmentChip' +import { TextAttachmentChip } from './attachments/TextAttachmentChip' import { DrawingAttachmentChip } from './attachments/DrawingAttachmentChip' import { FileAttachmentChip } from './attachments/FileAttachmentChip' import { type RefObject, useRef, useState } from 'react' @@ -83,11 +83,11 @@ export function ChatComposer({ const attachments = useLive(s => s.attachments[attachmentKey(workspaceId, sessionId)] ?? EMPTY) const [dragOver, setDragOver] = useState(false) - const uploading = attachments.some(a => a.kind !== 'context' && a.status === 'uploading') + const uploading = attachments.some(a => a.kind !== 'text' && a.status === 'uploading') // A draft annotation counts as sendable content: send() finishes the drawing // first, which uploads it before the message goes out. const hasSendable = attachments.some( - a => a.kind === 'context' || a.status === 'ready' || a.status === 'draft' + a => a.kind === 'text' || a.status === 'ready' || a.status === 'draft' ) const hasContent = value.trim().length > 0 || hasSendable const canSend = canSubmitComposerAction(hasContent, uploading, availability) @@ -112,7 +112,7 @@ export function ChatComposer({ onRemoveDrawing(attachment.localId) return } - liveStore.getState().removeAttachment(workspaceId, sessionId, attachment.localId) + liveStore.getState().removeAttachment(workspaceId, attachment.localId) } const send = async () => { @@ -161,8 +161,8 @@ export function ChatComposer({ {attachments .toSorted((a, b) => ATTACHMENT_ORDER[a.kind] - ATTACHMENT_ORDER[b.kind]) .map(a => - a.kind === 'context' ? ( - removeAttachment(a)} @@ -288,5 +288,5 @@ const EMPTY: ChatAttachment[] = [] const ATTACHMENT_ORDER: Record = { file: 0, drawing: 1, - context: 2 + text: 2 } diff --git a/client/features/chat/composer/attachments/ContextAttachmentChip.tsx b/client/features/chat/composer/attachments/TextAttachmentChip.tsx similarity index 64% rename from client/features/chat/composer/attachments/ContextAttachmentChip.tsx rename to client/features/chat/composer/attachments/TextAttachmentChip.tsx index b248207f..de4d0a6f 100644 --- a/client/features/chat/composer/attachments/ContextAttachmentChip.tsx +++ b/client/features/chat/composer/attachments/TextAttachmentChip.tsx @@ -1,11 +1,11 @@ import { IconLibrary } from '@tabler/icons-react' import { AttachmentChip } from './AttachmentChip' -type ContextAttachmentChipProps = { +type TextAttachmentChipProps = { label: string onRemove?: () => void } -export function ContextAttachmentChip({ label, onRemove }: ContextAttachmentChipProps) { +export function TextAttachmentChip({ label, onRemove }: TextAttachmentChipProps) { return } diff --git a/client/features/chat/composer/attachments/draft-attachments.test.ts b/client/features/chat/composer/attachments/draft-attachments.test.ts index bacf3919..8ad3a67a 100644 --- a/client/features/chat/composer/attachments/draft-attachments.test.ts +++ b/client/features/chat/composer/attachments/draft-attachments.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' -import { stageDrawing, stageDrawingDraft } from './draft-attachments' +import { + stageDrawing, + stageDrawingDraft, + stageChatAttachment, + stageTextAttachment +} from './draft-attachments' +import { attachmentsForSend } from '../../chat-send' +import * as appletLog from '../../../applets/applet-log' import { attachmentKey, liveStore } from '../../chat-store' const workspaceId = 'workspace-1' @@ -9,7 +16,7 @@ const originalFetch = globalThis.fetch function drawingAttachments() { return (liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] ?? []).filter( - a => a.kind !== 'context' + a => a.kind !== 'text' ) } @@ -187,3 +194,156 @@ describe('drawing attachment staging', () => { expect(attachment.kind === 'drawing' ? attachment.purpose : null).toBe('sketch') }) }) + +describe('applet file staging', () => { + test.each(['browser', 'path'] as const)( + '%s uploads follow both renames and keep their original chat', + async source => { + const requests: Array<{ + url: string + init?: RequestInit + resolve: (response: Response) => void + }> = [] + globalThis.fetch = mock( + (url: string, init?: RequestInit) => + new Promise(resolve => { + requests.push({ url, init, resolve }) + }) + ) as unknown as typeof fetch + const input = + source === 'browser' + ? { + type: 'file' as const, + file: new File(['snapshot'], 'notes.txt', { type: 'text/plain' }), + source: 'view:files' + } + : { type: 'file' as const, path: 'reports/notes.txt', source: 'view:files' } + const pending = stageChatAttachment({ workspaceId, sessionId: null }, input) + expect(attachmentsForSend(workspaceId, null)).toEqual([]) + expect(liveStore.getState().attachments[attachmentKey(workspaceId, null)][0]).toMatchObject({ + name: 'notes.txt', + status: 'uploading' + }) + if (source === 'path') { + expect(requests[0].url).toEndWith('/uploads/from-path') + expect(JSON.parse(requests[0].init?.body as string)).toEqual({ path: 'reports/notes.txt' }) + } else { + const form = requests[0].init?.body as FormData + expect(await (form.get('files') as File).text()).toBe('snapshot') + } + liveStore.getState().renameSession(workspaceId, null, 'temporary') + liveStore.getState().renameSession(workspaceId, 'temporary', 'real') + stageTextAttachment( + { workspaceId, sessionId: 'other' }, + { label: 'Other', text: 'Other chat', source: 'view:files' } + ) + const info = { + id: 'upload', + filename: 'notes.txt', + kind: 'file', + mediaType: 'text/plain', + size: 8 + } + requests[0].resolve(Response.json(source === 'browser' ? [info] : info)) + await pending + expect(attachmentsForSend(workspaceId, 'real')).toMatchObject([ + { kind: 'file', status: 'ready', upload: info } + ]) + expect(attachmentsForSend(workspaceId, 'other')).toHaveLength(1) + expect(attachmentsForSend(workspaceId, null)).toEqual([]) + expect(attachmentsForSend(workspaceId, 'real', { applet: { source: 'view:files' } })).toEqual( + [] + ) + } + ) + + test('a removed upload never returns, even after the chat is renamed', async () => { + let complete!: (response: Response) => void + globalThis.fetch = mock( + () => + new Promise(resolve => { + complete = resolve + }) + ) as unknown as typeof fetch + const pending = stageChatAttachment( + { workspaceId, sessionId }, + { type: 'file', path: 'image.png', source: 'view:files' } + ) + const id = drawingAttachments()[0].localId + liveStore.getState().renameSession(workspaceId, sessionId, 'real') + liveStore.getState().removeAttachment(workspaceId, id) + complete( + Response.json({ + id: 'upload', + filename: 'image.png', + kind: 'image', + mediaType: 'image/png', + size: 1 + }) + ) + await pending + expect(attachmentsForSend(workspaceId, 'real')).toEqual([]) + expect(attachmentsForSend(workspaceId, sessionId)).toEqual([]) + }) + + test('path images get an upload preview and failures get a removable error chip and log', async () => { + const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) + const requests: string[] = [] + globalThis.fetch = mock((url: string) => { + requests.push(url) + return Promise.resolve( + url.endsWith('/applet-log') + ? new Response(null, { status: 204 }) + : new Response('Missing file', { status: 400 }) + ) + }) as unknown as typeof fetch + await stageChatAttachment( + { workspaceId, sessionId }, + { type: 'file', path: 'missing.pdf', source: 'view:files' } + ) + const failed = drawingAttachments()[0] + expect(failed).toMatchObject({ status: 'error', error: 'Missing file' }) + expect(log).toHaveBeenCalledWith(workspaceId, { + source: 'runtime', + message: 'addChatAttachment() from view:files: Missing file' + }) + log.mockRestore() + liveStore.getState().removeAttachment(workspaceId, failed.localId) + expect(drawingAttachments()).toEqual([]) + globalThis.fetch = mock(() => + Promise.resolve( + Response.json({ id: 'img', filename: 'image.png', kind: 'image', mediaType: 'image/png' }) + ) + ) as unknown as typeof fetch + await stageChatAttachment( + { workspaceId, sessionId }, + { type: 'file', path: 'image.png', source: 'view:files' } + ) + expect(drawingAttachments()[0].previewUrl).toBe(`/api/workspaces/${workspaceId}/uploads/img`) + }) +}) + +test('drawing edits and final upload reuse the moved draft after a session rename', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + Response.json([{ id: 'drawing-upload', kind: 'image', mediaType: 'image/png' }]) + ) + ) as unknown as typeof fetch + const draft = { + workspaceId, + sessionId: null, + localId: 'renamed-drawing', + purpose: 'annotation' as const, + sourceTab: 'overview' as const, + blob: new Blob(['drawing'], { type: 'image/png' }) + } + stageDrawingDraft(draft) + liveStore.getState().renameSession(workspaceId, null, 'temporary') + stageDrawingDraft({ ...draft, blob: new Blob(['edited'], { type: 'image/png' }) }) + liveStore.getState().renameSession(workspaceId, 'temporary', 'real') + await stageDrawing({ ...draft, isCurrent: () => true }) + expect(attachmentsForSend(workspaceId, null)).toEqual([]) + expect(attachmentsForSend(workspaceId, 'real')).toMatchObject([ + { kind: 'drawing', localId: draft.localId, status: 'ready' } + ]) +}) diff --git a/client/features/chat/composer/attachments/draft-attachments.ts b/client/features/chat/composer/attachments/draft-attachments.ts index 2427da17..affa229c 100644 --- a/client/features/chat/composer/attachments/draft-attachments.ts +++ b/client/features/chat/composer/attachments/draft-attachments.ts @@ -1,49 +1,74 @@ -import type { ContextAttachment } from '@/lib/types' +import type { TextAttachment } from '@/lib/types' import type { WorkspaceTabId } from '@/lib/types' import { attachmentKey, liveStore } from '../../chat-store' -import type { - ChatAttachment, - DrawingPurpose -} from '@/client/features/chat/composer/attachments/types' -import { uploadFiles } from './uploads' +import type { DrawingPurpose } from './types' +import { uploadFiles, uploadWorkspaceFile } from './uploads' +import type { AppletChatAttachment } from '@/client/features/applets/applet-runtime' +import { reportAppletError } from '@/client/features/applets/applet-log' type ComposerTarget = { workspaceId: string sessionId: string | null } -export function stageComposerFiles( - { workspaceId, sessionId }: ComposerTarget, - files: File[] -): void { - if (files.length === 0) return +export function stageComposerFiles(target: ComposerTarget, files: File[]): void { + for (const file of files) void stageFile(target, file) +} - const items: ChatAttachment[] = files.map(file => ({ - kind: 'file', - localId: crypto.randomUUID(), - name: file.name || 'file', - mediaType: file.type || 'application/octet-stream', - previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, - status: 'uploading' - })) - liveStore.getState().addAttachments(workspaceId, sessionId, items) +// Both file sources enter the same draft lifecycle and become ordinary uploads. +async function stageFile( + { workspaceId, sessionId }: ComposerTarget, + input: File | string, + onError?: (message: string) => void +): Promise { + const file = typeof input === 'string' ? null : input + const localId = crypto.randomUUID() + liveStore.getState().addAttachments(workspaceId, sessionId, [ + { + kind: 'file', + localId, + name: typeof input === 'string' ? input.split('/').at(-1)! : input.name || 'file', + mediaType: file?.type || 'application/octet-stream', + previewUrl: file?.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, + status: 'uploading' + } + ]) + try { + const upload = + typeof input === 'string' + ? await uploadWorkspaceFile(workspaceId, input) + : (await uploadFiles(workspaceId, [input]))[0] + liveStore.getState().updateAttachment(workspaceId, localId, { + status: 'ready', + upload, + name: upload.filename, + mediaType: upload.mediaType, + ...(upload.kind === 'image' && !file?.type.startsWith('image/') + ? { previewUrl: `/api/workspaces/${workspaceId}/uploads/${upload.id}` } + : {}) + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Upload failed' + liveStore.getState().updateAttachment(workspaceId, localId, { status: 'error', error: message }) + onError?.(message) + } +} - items.forEach((item, index) => { - uploadFiles(workspaceId, [files[index]]) - .then(([upload]) => { - liveStore.getState().updateAttachment(workspaceId, sessionId, item.localId, { - status: 'ready', - upload, - mediaType: upload.mediaType - }) - }) - .catch((error: unknown) => { - liveStore.getState().updateAttachment(workspaceId, sessionId, item.localId, { - status: 'error', - error: error instanceof Error ? error.message : 'Upload failed' - }) - }) +export function stageChatAttachment( + target: ComposerTarget, + input: AppletChatAttachment +): Promise { + if (input.type === 'text') { + const { source, label, text } = input + stageTextAttachment(target, { source, label, text }) + return Promise.resolve() + } + return stageFile(target, input.file ?? input.path, message => { + reportAppletError(target.workspaceId, { + source: 'runtime', + message: `addChatAttachment() from ${input.source}: ${message}` + }) }) } @@ -68,12 +93,14 @@ export function stageDrawingDraft({ const label = purpose === 'sketch' ? 'Sketch' : 'Annotation' const previewUrl = URL.createObjectURL(blob) const store = liveStore.getState() - const existing = store.attachments[attachmentKey(workspaceId, sessionId)]?.some( - attachment => attachment.localId === localId + const existing = Object.entries(store.attachments).some( + ([key, attachments]) => + key.startsWith(`${workspaceId}:`) && + attachments.some(attachment => attachment.localId === localId) ) if (existing) { - store.updateAttachment(workspaceId, sessionId, localId, { + store.updateAttachment(workspaceId, localId, { previewUrl, status: 'draft', upload: undefined, @@ -114,12 +141,14 @@ export async function stageDrawing({ const label = purpose === 'sketch' ? 'Sketch' : 'Annotation' const previewUrl = URL.createObjectURL(blob) const store = liveStore.getState() - const existing = store.attachments[attachmentKey(workspaceId, sessionId)]?.some( - attachment => attachment.localId === localId + const existing = Object.entries(store.attachments).some( + ([key, attachments]) => + key.startsWith(`${workspaceId}:`) && + attachments.some(attachment => attachment.localId === localId) ) if (existing) { - store.updateAttachment(workspaceId, sessionId, localId, { + store.updateAttachment(workspaceId, localId, { previewUrl, status: 'uploading', upload: undefined, @@ -145,7 +174,7 @@ export async function stageDrawing({ new File([blob], `${label}.png`, { type: 'image/png' }) ]) if (!isCurrent()) return - liveStore.getState().updateAttachment(workspaceId, sessionId, localId, { + liveStore.getState().updateAttachment(workspaceId, localId, { status: 'ready', upload, mediaType: upload.mediaType, @@ -153,30 +182,29 @@ export async function stageDrawing({ }) } catch { if (!isCurrent()) return - liveStore.getState().removeAttachment(workspaceId, sessionId, localId) + liveStore.getState().removeAttachment(workspaceId, localId) } } -// Duplicate context is ignored; the caller still reveals chat. -export function stageChatContext( +// Duplicate text is ignored; the caller still reveals chat. +export function stageTextAttachment( { workspaceId, sessionId }: ComposerTarget, - attachment: ContextAttachment + attachment: TextAttachment ): void { const store = liveStore.getState() const pending = (store.attachments[attachmentKey(workspaceId, sessionId)] ?? []).filter( - item => item.kind === 'context' + item => item.kind === 'text' ) - const json = JSON.stringify(attachment.context) if ( pending.some( item => item.attachment.source === attachment.source && item.attachment.label === attachment.label && - JSON.stringify(item.attachment.context) === json + item.attachment.text === attachment.text ) ) return store.addAttachments(workspaceId, sessionId, [ - { kind: 'context', localId: crypto.randomUUID(), name: attachment.label, attachment } + { kind: 'text', localId: crypto.randomUUID(), name: attachment.label, attachment } ]) } diff --git a/client/features/chat/composer/attachments/context-attachments.test.ts b/client/features/chat/composer/attachments/text-attachments.test.ts similarity index 69% rename from client/features/chat/composer/attachments/context-attachments.test.ts rename to client/features/chat/composer/attachments/text-attachments.test.ts index c01e8cf2..00992e1d 100644 --- a/client/features/chat/composer/attachments/context-attachments.test.ts +++ b/client/features/chat/composer/attachments/text-attachments.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { appletRuntime } from '../../../applets/applet-runtime' -import { stageChatContext } from './draft-attachments' +import { stageTextAttachment, stageChatAttachment } from './draft-attachments' import { attachmentKey, liveStore } from '../../chat-store' import { attachmentsForSend, @@ -10,44 +10,46 @@ import { const workspaceId = 'context-test' const target = { workspaceId, sessionId: null } -const attachment = { source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } } +const attachment = { source: 'view:orders', label: 'Order #1042', text: 'Order ID: 1042' } afterEach(() => liveStore.setState({ attachments: {} })) -describe('context staging and sends', () => { +describe('text staging and sends', () => { test('stamps source, snapshots at the bridge and ignores disposed connections', () => { const runtime = appletRuntime(workspaceId) - const off = runtime.on('addChatContext', value => { - stageChatContext(target, value) + const off = runtime.on('addChatAttachment', value => { + void stageChatAttachment(target, value) }) const { bridge, dispose } = runtime.connect({ kind: 'view', name: 'orders' }) - const input = { label: 'Order', context: { status: 'pending' }, source: 'widget:forged' } - bridge.addChatContext(input) - input.context.status = 'done' + const input = { type: 'text', label: 'Order', text: 'Status: pending', source: 'widget:forged' } + bridge.addChatAttachment(input) + input.text = 'Status: done' dispose() - bridge.addChatContext({ label: 'Another', context: {} }) + bridge.addChatAttachment({ type: 'text', label: 'Another', text: 'Order' }) off() expect(attachmentsForSend(workspaceId, null)).toMatchObject([ { - kind: 'context', - attachment: { source: 'view:orders', label: 'Order', context: { status: 'pending' } } + kind: 'text', + attachment: { source: 'view:orders', label: 'Order', text: 'Status: pending' } } ]) }) test('skips identical repeats and stages more than ten different payloads', () => { - stageChatContext(target, attachment) - stageChatContext(target, structuredClone(attachment)) + stageTextAttachment(target, attachment) + stageTextAttachment(target, structuredClone(attachment)) expect(attachmentsForSend(workspaceId, null)).toHaveLength(1) - for (let i = 1; i < 20; i++) - stageChatContext(target, { ...attachment, context: { orderId: String(i) } }) + for (let i = 1; i < 20; i++) stageTextAttachment(target, { ...attachment, text: String(i) }) expect(attachmentsForSend(workspaceId, null)).toHaveLength(20) - stageChatContext(target, attachment) + stageTextAttachment(target, attachment) expect(attachmentsForSend(workspaceId, null)).toHaveLength(20) }) test('keeps chats isolated and follows session renames', () => { - stageChatContext(target, attachment) - stageChatContext({ workspaceId, sessionId: 'existing' }, { ...attachment, label: 'Existing' }) + stageTextAttachment(target, attachment) + stageTextAttachment( + { workspaceId, sessionId: 'existing' }, + { ...attachment, label: 'Existing' } + ) expect(attachmentsForSend(workspaceId, 'different')).toEqual([]) liveStore.getState().renameSession(workspaceId, 'existing', 'renamed') expect(attachmentsForSend(workspaceId, 'renamed')[0].name).toBe('Existing') @@ -56,10 +58,10 @@ describe('context staging and sends', () => { expect(attachmentsForSend(workspaceId, 'renamed')).toHaveLength(1) }) - test('an immediate send keeps new-chat context staged through both session id changes', () => { - stageChatContext(target, attachment) - stageChatContext(target, { ...attachment, label: 'Another order' }) - stageChatContext({ workspaceId, sessionId: 'other' }, { ...attachment, label: 'Other chat' }) + test('an immediate send keeps new-chat text staged through both session id changes', () => { + stageTextAttachment(target, attachment) + stageTextAttachment(target, { ...attachment, label: 'Another order' }) + stageTextAttachment({ workspaceId, sessionId: 'other' }, { ...attachment, label: 'Other chat' }) const staged = attachmentsForSend(workspaceId, null) const options = { applet: { source: 'view:orders' } } @@ -77,8 +79,8 @@ describe('context staging and sends', () => { expect(attachmentsForSend(workspaceId, 'other')[0].name).toBe('Other chat') }) - test('sends context with uploads without changing annotation image positions', () => { - stageChatContext(target, attachment) + test('sends text with uploads without changing annotation image positions', () => { + stageTextAttachment(target, attachment) liveStore.getState().addAttachments(workspaceId, null, [ { kind: 'drawing', @@ -99,7 +101,10 @@ describe('context staging and sends', () => { ]) const ready = attachmentsForSend(workspaceId, null) expect(ready).toHaveLength(2) - expect(attachmentPartsForOptimisticTurn(ready)[0]).toEqual({ type: 'context', ...attachment }) + expect(attachmentPartsForOptimisticTurn(ready)[0]).toEqual({ + type: 'text-attachment', + ...attachment + }) expect(withAttachmentDirectives(undefined, ready)?.directives).toEqual([ 'Annotation attachment sources in attachment order: 1. "view:orders".' ]) diff --git a/client/features/chat/composer/attachments/types.ts b/client/features/chat/composer/attachments/types.ts index 7530faf4..e981e3c7 100644 --- a/client/features/chat/composer/attachments/types.ts +++ b/client/features/chat/composer/attachments/types.ts @@ -1,4 +1,4 @@ -import type { ContextAttachment, UploadInfo, WorkspaceTabId } from '@/lib/types' +import type { TextAttachment, UploadInfo, WorkspaceTabId } from '@/lib/types' // One composer attachment, tracked per session until the message is sent. A // file uploads as soon as it's added (drop/paste/pick); an annotation stays a @@ -26,7 +26,7 @@ export type UploadedChatAttachment = UploadAttachmentBase & export type ChatAttachment = | UploadedChatAttachment - | { kind: 'context'; localId: string; name: string; attachment: ContextAttachment } + | { kind: 'text'; localId: string; name: string; attachment: TextAttachment } export type ChatAttachmentPatch = Partial< Pick diff --git a/client/features/chat/composer/attachments/uploads.ts b/client/features/chat/composer/attachments/uploads.ts index ac07d56b..32d7b4bd 100644 --- a/client/features/chat/composer/attachments/uploads.ts +++ b/client/features/chat/composer/attachments/uploads.ts @@ -16,3 +16,13 @@ export async function uploadFiles(workspaceId: string, files: File[]): Promise { + const res = await fetch(`/api/workspaces/${workspaceId}/uploads/from-path`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) + if (!res.ok) throw new Error(await res.text().catch(() => `Upload failed (${res.status})`)) + return res.json() +} diff --git a/client/features/chat/messages/TurnView.tsx b/client/features/chat/messages/TurnView.tsx index da52865b..9cc89282 100644 --- a/client/features/chat/messages/TurnView.tsx +++ b/client/features/chat/messages/TurnView.tsx @@ -1,4 +1,4 @@ -import { ContextAttachmentChip } from '../composer/attachments/ContextAttachmentChip' +import { TextAttachmentChip } from '../composer/attachments/TextAttachmentChip' import { memo } from 'react' import { IconChevronRight, IconFile } from '@tabler/icons-react' @@ -166,18 +166,18 @@ export const TurnView = memo(function TurnView({ turn, processing = false }: Tur // Plain user input — right-aligned. Attachments (images/files) stack above // the text bubble. const fileParts = turn.parts.filter( - (p): p is Extract => p.type === 'file' + (p): p is Extract => p.type === 'file-attachment' ) - const contextParts = turn.parts.filter(p => p.type === 'context') + const textAttachmentParts = turn.parts.filter(p => p.type === 'text-attachment') const text = turn.parts .filter(p => p.type === 'text') .map(p => (p.type === 'text' ? p.text : '')) .join('\n') - if (!text && fileParts.length === 0 && contextParts.length === 0) return null + if (!text && fileParts.length === 0 && textAttachmentParts.length === 0) return null return (
- {contextParts.map((p, i) => ( - + {textAttachmentParts.map((p, i) => ( + ))} {fileParts.map((p, i) => ( @@ -212,15 +212,15 @@ type PartRendererProps = { part: Part } // folded into a run by buildSegments. function PartRenderer({ part }: PartRendererProps) { switch (part.type) { - case 'context': - return + case 'text-attachment': + return case 'text': return case 'tool-call': // Tool calls normally fold into a run; this is a defensive fallback for a // lone tool-call segment, rendered as a one-row group. return - case 'file': + case 'file-attachment': return case 'source-url': return diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 759d0225..97cf7739 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -118,7 +118,7 @@ export function useChat(address: WorkspaceTabAddress) { if (!sid) { sid = crypto.randomUUID() isNew = true - // An immediate applet send leaves the user's context staged in this chat. + // An immediate applet send leaves the user's attachments staged in this chat. if (!ownsComposerAttachments(options)) { liveStore.getState().renameSession(workspaceId, selectedSessionId, sid) } diff --git a/client/features/dev/chat-states-fixtures.ts b/client/features/dev/chat-states-fixtures.ts index fe4a5ab8..f3d2b2a9 100644 --- a/client/features/dev/chat-states-fixtures.ts +++ b/client/features/dev/chat-states-fixtures.ts @@ -526,13 +526,13 @@ const moonReplyWithMeta: Turn = { // Beat 12 — attachments: image thumbnail + non-image file chip on one user turn. const attachmentsTurn = turn('user', 23, 26, [ { - type: 'context', + type: 'text-attachment', source: 'view:board', label: 'Selected board items', - context: { itemIds: ['item-1', 'item-2'] } + text: 'Selected items: item-1, item-2' }, - { type: 'file', mediaType: 'image/png', url: CHECKERBOARD_PNG, filename: 'pattern.png' }, - { type: 'file', mediaType: 'application/pdf', url: '', filename: 'report.pdf' }, + { type: 'file-attachment', mediaType: 'image/png', url: CHECKERBOARD_PNG, filename: 'pattern.png' }, + { type: 'file-attachment', mediaType: 'application/pdf', url: '', filename: 'report.pdf' }, text( 'back to work — mole says the report draft is done. recreate this dither pattern in the scratchpad later, and let him know we got it' ) diff --git a/client/features/drawings/useChatAnnotation.ts b/client/features/drawings/useChatAnnotation.ts index da549fbd..786750d4 100644 --- a/client/features/drawings/useChatAnnotation.ts +++ b/client/features/drawings/useChatAnnotation.ts @@ -61,9 +61,7 @@ export function useChatAnnotation({ latestBlobRef.current = blob if (!blob) { - liveStore - .getState() - .removeAttachment(draft.workspaceId, draft.sourceSessionId, draft.attachmentId) + liveStore.getState().removeAttachment(draft.workspaceId, draft.attachmentId) return } @@ -152,14 +150,14 @@ export function useChatAnnotation({ (localId: string) => { const revision = (uploadRevisionsRef.current.get(localId) ?? 0) + 1 uploadRevisionsRef.current.set(localId, revision) - liveStore.getState().removeAttachment(workspaceId, sessionId, localId) + liveStore.getState().removeAttachment(workspaceId, localId) if (draftRef.current?.attachmentId === localId) { draftRef.current = null latestBlobRef.current = null void cancelLayer() } }, - [cancelLayer, sessionId, workspaceId] + [cancelLayer, workspaceId] ) const finishDrawing = useCallback(async () => { diff --git a/client/features/drawings/useViewBuilderSketch.ts b/client/features/drawings/useViewBuilderSketch.ts index cd2d3925..6c8ac45c 100644 --- a/client/features/drawings/useViewBuilderSketch.ts +++ b/client/features/drawings/useViewBuilderSketch.ts @@ -50,7 +50,7 @@ export function useViewBuilderSketch({ uploadRevisionRef.current += 1 uploadedBlobRef.current = null pendingStageRef.current = null - liveStore.getState().removeAttachment(workspaceId, sessionId, attachmentIdRef.current) + liveStore.getState().removeAttachment(workspaceId, attachmentIdRef.current) return } @@ -79,7 +79,7 @@ export function useViewBuilderSketch({ .attachments[attachmentKey(workspaceId, sessionId)]?.some( attachment => attachment.localId === attachmentIdRef.current && - attachment.kind !== 'context' && + attachment.kind !== 'text' && (attachment.status === 'ready' || attachment.status === 'uploading') ) if (staged) return pendingStageRef.current ?? Promise.resolve() @@ -132,13 +132,13 @@ export function useViewBuilderSketch({ uploadRevisionRef.current += 1 uploadedBlobRef.current = null pendingStageRef.current = null - liveStore.getState().removeAttachment(workspaceId, sessionId, attachmentIdRef.current) + liveStore.getState().removeAttachment(workspaceId, attachmentIdRef.current) try { await controls.cancel() } finally { acceptExportsRef.current = true } - }, [controls, sessionId, workspaceId]) + }, [controls, workspaceId]) useEffect(() => { if (!active && controls.active) void deactivate() diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 4c6dc79b..607e9bb5 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -26,7 +26,7 @@ import { Button } from '@/client/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { useAppletChatMessage, - useAppletChatContext + useAppletChatAttachment } from '@/client/features/chat/applet-chat-intents' import { useChat } from '@/client/features/chat/useChat' import { ViewBuilder, type ViewBuilderHandle } from '@/client/features/views/ViewBuilder' @@ -457,7 +457,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // in full-screen mode the chat is a closed popover, and a run the user can't // see is worse than a panel that opens itself. useAppletChatMessage({ send, revealChat: openChat, agentAvailability }) - useAppletChatContext(sessionId, openChat) + useAppletChatAttachment(sessionId, openChat) const createItems: CreateWorkspaceTabItem[] = [ ...(!dockedSplit && !openSet.has('agent') diff --git a/dev/file-uploads.md b/dev/file-uploads.md index 83492754..b58003e1 100644 --- a/dev/file-uploads.md +++ b/dev/file-uploads.md @@ -150,7 +150,7 @@ affordance. ## Part 5 — Adding a new adapter -The display format is agent-agnostic: a `Part` of `{ type: 'file', mediaType, +The display format is agent-agnostic: a `Part` of `{ type: 'file-attachment', mediaType, url, filename }` (url = served URL preferred, data URL only as a reload fallback). For a new backend: (1) convert resolved uploads into the backend's message shape (base64 blocks preferred; path/file-id otherwise — `StoredUpload` diff --git a/lib/chat-title.ts b/lib/chat-title.ts index fb7154ab..a3df562b 100644 --- a/lib/chat-title.ts +++ b/lib/chat-title.ts @@ -1,7 +1,7 @@ -import { stripContextAttachmentsLoose } from './moi-attachments' +import { stripTextAttachmentsLoose } from './moi-attachments' const MAX_CHAT_TITLE_LENGTH = 64 export function formatChatTitle(text: string, filenames: readonly string[] = []): string { - const source = stripContextAttachmentsLoose(text).trim() || filenames.filter(Boolean).join(', ') + const source = stripTextAttachmentsLoose(text).trim() || filenames.filter(Boolean).join(', ') return source.replace(/\s+/gu, ' ').trim().slice(0, MAX_CHAT_TITLE_LENGTH).trimEnd() } diff --git a/lib/format.ts b/lib/format.ts index afcf442e..3e885d3d 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,4 +1,4 @@ -import type { ContextAttachment } from './types' +import type { TextAttachment } from './types' // Display-only conversation format. Agent-agnostic. // Source: server/harness/README.md (format section; formerly claude-code-messages.md §14) (and the research PDF under dev/report). @@ -52,7 +52,7 @@ export type ToolCall = { export type Citation = { url?: string; title?: string; quote?: string } export type Part = - | ({ type: 'context' } & ContextAttachment) + | ({ type: 'text-attachment' } & TextAttachment) | { type: 'text'; text: string; citations?: Citation[] } // `redacted` marks reasoning the backend acknowledges but won't hand over: // Anthropic's `redacted_thinking`, and the codex app-server's `Reasoning` @@ -66,7 +66,7 @@ export type Part = durationMs?: number } | { type: 'tool-call'; call: ToolCall } - | { type: 'file'; mediaType: string; url: string; filename?: string } + | { type: 'file-attachment'; mediaType: string; url: string; filename?: string } | { type: 'source-url'; url: string; title?: string; sourceId: string } | { type: 'source-document'; mediaType: string; title: string; sourceId: string } | { type: 'data'; name: string; data: unknown } diff --git a/lib/message-attachments.test.ts b/lib/message-attachments.test.ts index 58a19d9a..b40f5d58 100644 --- a/lib/message-attachments.test.ts +++ b/lib/message-attachments.test.ts @@ -3,18 +3,18 @@ import { expect, test } from 'bun:test' import { isMessageAttachments, partitionMessageAttachments } from './message-attachments' test('partitions mixed attachments and leaves saved context payloads unchanged', () => { - const context = { source: 'view:orders', label: 'Order', context: { id: '1042' } } + const context = { source: 'view:orders', label: 'Order', text: 'Order ID: 1042' } const attachments: MessageAttachment[] = [ { type: 'upload', uploadId: 'file' }, - { type: 'context', ...context }, + { type: 'text', ...context }, { type: 'upload', uploadId: 'drawing' } ] expect(isMessageAttachments(attachments)).toBe(true) expect(partitionMessageAttachments(attachments)).toEqual({ uploadIds: ['file', 'drawing'], - contextAttachments: [context] + textAttachments: [context] }) - expect(partitionMessageAttachments()).toEqual({ uploadIds: [], contextAttachments: [] }) + expect(partitionMessageAttachments()).toEqual({ uploadIds: [], textAttachments: [] }) }) test('rejects invalid variants, legacy ids, and invalid context in mixed requests', () => { @@ -24,12 +24,12 @@ test('rejects invalid variants, legacy ids, and invalid context in mixed request [{ type: 'upload' }], [{ type: 'upload', uploadId: '' }], [{ type: 'unknown' }], - [{ type: 'context', label: 'Bad' }] + [{ type: 'text', label: 'Bad' }] ]) { expect(isMessageAttachments(value)).toBe(false) } - const item = { type: 'context', source: 'view:orders', label: 'Order', context: {} } + const item = { type: 'text', source: 'view:orders', label: 'Order', text: 'Order' } expect(isMessageAttachments(Array(20).fill(item))).toBe(true) - expect(isMessageAttachments([{ ...item, context: { text: 'x'.repeat(5001) } }])).toBe(false) + expect(isMessageAttachments([{ ...item, text: 'x'.repeat(5001) }])).toBe(false) expect(isMessageAttachments([])).toBe(true) }) diff --git a/lib/message-attachments.ts b/lib/message-attachments.ts index 20402d9d..fe2f1537 100644 --- a/lib/message-attachments.ts +++ b/lib/message-attachments.ts @@ -1,31 +1,43 @@ -import { isContextAttachments } from './moi-attachments' -import type { ContextAttachment } from './types' +import { isTextAttachments } from './moi-attachments' +import type { TextAttachment } from './types' import type { MessageAttachment } from './types' export function isMessageAttachments(value: unknown): value is MessageAttachment[] { if (!Array.isArray(value)) return false - const contexts: unknown[] = [] + const texts: unknown[] = [] for (const item of value) { if (!item || typeof item !== 'object') return false if (item.type === 'upload') { if (typeof item.uploadId !== 'string' || !item.uploadId) return false - } else if (item.type === 'context') { - contexts.push(item) + } else if (item.type === 'text') { + texts.push(item) } else return false } - return isContextAttachments(contexts) + return isTextAttachments(texts) } export function partitionMessageAttachments(attachments: readonly MessageAttachment[] = []) { const uploadIds: string[] = [] - const contextAttachments: ContextAttachment[] = [] + const textAttachments: TextAttachment[] = [] for (const attachment of attachments) { if (attachment.type === 'upload') uploadIds.push(attachment.uploadId) else { - const { source, label, context } = attachment - contextAttachments.push({ source, label, context }) + const { source, label, text } = attachment + textAttachments.push({ source, label, text }) } } - return { uploadIds, contextAttachments } + return { uploadIds, textAttachments } +} + +// Shared by browser validation and both server upload routes. +export const MAX_UPLOAD_BYTES = 32 * 1024 * 1024 + +export function isWorkspaceAttachmentPath(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !/[\\:\0]/.test(value) && + value.split('/').every(segment => segment.length > 0 && !segment.startsWith('.')) + ) } diff --git a/lib/moi-attachments.test.ts b/lib/moi-attachments.test.ts index 194c0424..842e6d0a 100644 --- a/lib/moi-attachments.test.ts +++ b/lib/moi-attachments.test.ts @@ -1,51 +1,36 @@ import { describe, expect, test } from 'bun:test' import { - appendContextAttachments, - contextAttachmentParts, - isContextAttachments, - snapshotChatContext, - splitContextAttachments, - stripContextAttachmentsLoose + appendTextAttachments, + textAttachmentParts, + isTextAttachments, + snapshotTextAttachment, + splitTextAttachments, + stripTextAttachmentsLoose } from './moi-attachments' import { appendAttachmentNote, splitAttachmentNote } from './attachment-note' import { formatChatTitle } from './chat-title' -const attachment = { source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } } +const attachment = { source: 'view:orders', label: 'Order #1042', text: 'Order ID: 1042' } -describe('context attachments', () => { - test('captures a detached JSON snapshot', () => { - const input = { label: ' Order ', context: { nested: { status: 'pending' } } } - const captured = snapshotChatContext(input) - input.context.nested.status = 'done' - expect(captured).toEqual({ label: 'Order', context: { nested: { status: 'pending' } } }) +describe('text attachments', () => { + test('copies the label and preserves text exactly', () => { + const input = { label: ' Order ', text: ' Status: pending\n' } + const captured = snapshotTextAttachment(input) + input.text = 'Status: done' + expect(captured).toEqual({ label: 'Order', text: ' Status: pending\n' }) }) - test('rejects invalid, lossy and oversized payloads', () => { - const cycle: Record = {} - cycle.self = cycle - for (const context of [ - null, - [], - 'text', - { fn: () => {} }, - { n: NaN }, - { n: Infinity }, - { n: 1n }, - { missing: undefined }, - { date: new Date() }, - cycle, - { long: 'x'.repeat(5000) } - ]) { - expect(snapshotChatContext({ label: 'Order', context })).toBeNull() + test('rejects invalid and oversized text and labels', () => { + for (const text of [null, [], {}, 42, '', ' ', 'x'.repeat(5001)]) { + expect(snapshotTextAttachment({ label: 'Order', text })).toBeNull() } for (const label of ['', ' ', 'x'.repeat(121), 5]) - expect(snapshotChatContext({ label, context: {} })).toBeNull() + expect(snapshotTextAttachment({ label, text: 'Order' })).toBeNull() expect( - snapshotChatContext({ label: 'x'.repeat(120), context: { text: 'x'.repeat(4989) } }) + snapshotTextAttachment({ label: 'x'.repeat(120), text: 'x'.repeat(5000) }) ).not.toBeNull() - expect(snapshotChatContext({ label: 'Order', context: { text: 'x'.repeat(4990) } })).toBeNull() - expect(isContextAttachments(Array(20).fill(attachment))).toBe(true) - expect(isContextAttachments([{ ...attachment, source: '' }])).toBe(false) + expect(isTextAttachments(Array(20).fill(attachment))).toBe(true) + expect(isTextAttachments([{ ...attachment, source: '' }])).toBe(false) }) test('round-trips multiple items and escapes delimiters in labels and data', () => { @@ -54,27 +39,29 @@ describe('context attachments', () => { { ...attachment, label: '', - context: { text: '' } + text: '' } ] - const wire = appendContextAttachments('Help with these', attachments) + const wire = appendTextAttachments('Help with these', attachments) expect(wire.match(/<\/moi-attachments>/g)).toHaveLength(1) - expect(splitContextAttachments(wire)).toEqual({ text: 'Help with these', attachments }) - expect(contextAttachmentParts(attachments)).toEqual( - attachments.map(a => ({ type: 'context', ...a })) + expect(splitTextAttachments(wire)).toEqual({ text: 'Help with these', attachments }) + expect(textAttachmentParts(attachments)).toEqual( + attachments.map(a => ({ type: 'text-attachment', ...a })) ) - expect(splitContextAttachments(appendContextAttachments('', attachments)).text).toBe('') + expect(splitTextAttachments(appendTextAttachments('', attachments)).text).toBe('') }) test('leaves ordinary, malformed and future-version text intact', () => { for (const text of [ 'hello', + 'What does mean?', '', 'broken', '{"version":2,"attachments":[]}', '{"version":1,"attachments":[{}]}' ]) { - expect(splitContextAttachments(text)).toEqual({ text, attachments: [] }) + expect(splitTextAttachments(text)).toEqual({ text, attachments: [] }) + expect(stripTextAttachmentsLoose(text)).toBe(text) } }) @@ -85,7 +72,7 @@ describe('context attachments', () => { 'broken', '{"version":2,"attachments":[]}' ]) { - expect(splitContextAttachments(appendContextAttachments(text, [attachment]))).toEqual({ + expect(splitTextAttachments(appendTextAttachments(text, [attachment]))).toEqual({ text, attachments: [attachment] }) @@ -94,13 +81,13 @@ describe('context attachments', () => { test('coexists with file notes and hides metadata from truncated previews', () => { const files = [{ filename: 'notes.txt', path: '/tmp/notes.txt' }] - const wire = appendAttachmentNote(appendContextAttachments('Review', [attachment]), files) - expect(splitAttachmentNote(splitContextAttachments(wire).text)).toEqual({ + const wire = appendAttachmentNote(appendTextAttachments('Review', [attachment]), files) + expect(splitAttachmentNote(splitTextAttachments(wire).text)).toEqual({ text: 'Review', files }) - const truncated = appendContextAttachments('Review', [attachment]).slice(0, -15) - expect(stripContextAttachmentsLoose(truncated)).toBe('Review') + const truncated = appendTextAttachments('Review', [attachment]).slice(0, -15) + expect(stripTextAttachmentsLoose(truncated)).toBe('Review') expect(formatChatTitle(truncated)).toBe('Review') }) }) diff --git a/lib/moi-attachments.ts b/lib/moi-attachments.ts index 3ab0abe0..1a570c47 100644 --- a/lib/moi-attachments.ts +++ b/lib/moi-attachments.ts @@ -3,52 +3,28 @@ import type { Part } from './format' import { isParamsRecord } from './workspace-tabs' -import type { ChatContextInput, ContextAttachment } from './types' -export const MAX_CONTEXT_ATTACHMENT_CHARS = 5000 -export const MAX_CONTEXT_LABEL_CHARS = 120 +import type { TextAttachmentInput, TextAttachment } from './types' +export const MAX_TEXT_ATTACHMENT_CHARS = 5000 +export const MAX_ATTACHMENT_LABEL_CHARS = 120 const OPEN = '' const CLOSE = '' -// Reject values JSON would silently change (functions, undefined, Dates, -// non-finite numbers, etc.). Repeated references are fine; cycles are not. -function isJson(value: unknown, parents = new Set()): boolean { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true - if (typeof value === 'number') return Number.isFinite(value) - if (typeof value !== 'object' || parents.has(value)) return false +// Copy the display label and immutable text when the user selects it. +export function snapshotTextAttachment(value: unknown): TextAttachmentInput | null { + if (!isParamsRecord(value) || typeof value.label !== 'string') return null + const label = value.label.trim() if ( - !Array.isArray(value) && - Object.getPrototypeOf(value) !== Object.prototype && - Object.getPrototypeOf(value) !== null + !label || + label.length > MAX_ATTACHMENT_LABEL_CHARS || + typeof value.text !== 'string' || + !value.text.trim() || + value.text.length > MAX_TEXT_ATTACHMENT_CHARS ) - return false - parents.add(value) - const valid = - Object.getOwnPropertySymbols(value).length === 0 && - (Array.isArray(value) ? Array.from(value) : Object.values(value)).every(v => isJson(v, parents)) - parents.delete(value) - return valid -} - -export function snapshotChatContext(value: unknown): ChatContextInput | null { - try { - if (!isParamsRecord(value) || typeof value.label !== 'string') return null - const label = value.label.trim() - if ( - !label || - label.length > MAX_CONTEXT_LABEL_CHARS || - !isParamsRecord(value.context) || - !isJson(value.context) - ) - return null - const json = JSON.stringify(value.context) - if (json.length > MAX_CONTEXT_ATTACHMENT_CHARS) return null - return { label, context: JSON.parse(json) as Record } - } catch { return null - } + return { label, text: value.text } } -export function isContextAttachments(value: unknown): value is ContextAttachment[] { +export function isTextAttachments(value: unknown): value is TextAttachment[] { return ( Array.isArray(value) && value.every( @@ -56,37 +32,37 @@ export function isContextAttachments(value: unknown): value is ContextAttachment isParamsRecord(item) && typeof item.source === 'string' && /^(widget|view):[^/]+$/.test(item.source) && - snapshotChatContext(item) !== null + snapshotTextAttachment(item) !== null ) ) } -export function contextAttachmentParts(attachments: readonly ContextAttachment[] = []): Part[] { - return attachments.map(({ source, label, context }) => ({ - type: 'context', +export function textAttachmentParts(attachments: readonly TextAttachment[] = []): Part[] { + return attachments.map(({ source, label, text }) => ({ + type: 'text-attachment', source, label, - context + text })) } -export function appendContextAttachments( +export function appendTextAttachments( text: string, - attachments: readonly ContextAttachment[] = [] + attachments: readonly TextAttachment[] = [] ): string { if (!attachments.length) return text - if (!isContextAttachments(attachments)) throw new Error('Invalid context attachments') + if (!isTextAttachments(attachments)) throw new Error('Invalid text attachments') const json = JSON.stringify({ version: 1, attachments }).replaceAll('<', '\\u003c') return `${text}\n\n${OPEN}\n${json}\n${CLOSE}`.trim() } // Only a complete, valid v1 block is hidden. Ordinary text and malformed or // future-version blocks are left intact instead of silently eating user text. -export function splitContextAttachments(text: string): { +export function splitTextAttachments(text: string): { text: string - attachments: ContextAttachment[] + attachments: TextAttachment[] } { - const attachments: ContextAttachment[] = [] + const attachments: TextAttachment[] = [] // Serialized JSON escapes '<', so a candidate cannot span another opening // tag. A literal tag in the user's text must not swallow the saved block. const cleaned = text.replace( @@ -97,7 +73,7 @@ export function splitContextAttachments(text: string): { if ( !isParamsRecord(parsed) || parsed.version !== 1 || - !isContextAttachments(parsed.attachments) + !isTextAttachments(parsed.attachments) ) return block attachments.push(...parsed.attachments) @@ -110,13 +86,15 @@ export function splitContextAttachments(text: string): { return { text: attachments.length ? cleaned.trim() : text, attachments } } -export function stripContextAttachments(text: string): string { - return splitContextAttachments(text).text +export function stripTextAttachments(text: string): string { + return splitTextAttachments(text).text } // Truncated session previews may end halfway through the metadata block. -export function stripContextAttachmentsLoose(text: string): string { - const clean = stripContextAttachments(text) - const start = clean.indexOf(OPEN) - return (start < 0 ? clean : clean.slice(0, start)).trimEnd() +export function stripTextAttachmentsLoose(text: string): string { + const clean = stripTextAttachments(text) + const start = clean.search(/\s*\{"version":1,"attachments":\[/) + // Only hide an unfinished block with our serialized prefix. Literal tags and + // complete malformed blocks still belong to the user's visible text. + return (start < 0 || clean.includes(CLOSE, start) ? clean : clean.slice(0, start)).trimEnd() } diff --git a/lib/types.ts b/lib/types.ts index 0a3d2d20..db80f77c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -208,12 +208,16 @@ export type UploadInfo = { // Client → Server messages. // The chat WebSocket is app-wide (one socket for the whole client, not scoped to // a workspace), so every message carries the `workspaceId` it targets. -export type ChatContextInput = { label: string; context: Record } -export type ContextAttachment = ChatContextInput & { source: string } +export type TextAttachmentInput = { label: string; text: string } +export type ChatAttachmentInput = + | ({ type: 'text' } & TextAttachmentInput) + | { type: 'file'; file: File; path?: never } + | { type: 'file'; path: string; file?: never } +export type TextAttachment = TextAttachmentInput & { source: string } export type MessageAttachment = | { type: 'upload'; uploadId: string } - | ({ type: 'context' } & ContextAttachment) + | ({ type: 'text' } & TextAttachment) export type ClientMessage = | { diff --git a/server/api-upload-path.test.ts b/server/api-upload-path.test.ts new file mode 100644 index 00000000..9e06cd8c --- /dev/null +++ b/server/api-upload-path.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdir, mkdtemp, rm, symlink, truncate } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { api } from './api' +import { DEFAULT_REGISTRY_PATH, registerWorkspace, setRegistryPath } from './registry' +import { getUpload, MAX_UPLOAD_BYTES } from './uploads' +import type { UploadInfo } from '@/lib/types' + +let temp: string +let root: string +let workspaceId: string +beforeEach(async () => { + temp = await mkdtemp(join(tmpdir(), 'moi-path-upload-')) + root = join(temp, 'workspace') + await mkdir(root) + setRegistryPath(join(temp, 'registry.json')) + workspaceId = (await registerWorkspace(root, { type: 'codex' })).id +}) +afterEach(async () => { + setRegistryPath(DEFAULT_REGISTRY_PATH) + await rm(temp, { recursive: true, force: true }) +}) +function attach(path: unknown) { + return api.request(`/api/workspaces/${workspaceId}/uploads/from-path`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) +} + +test('snapshots text, JSON and PDFs using the existing upload store', async () => { + for (const name of ['notes.txt', 'data.json', 'report.pdf']) { + await Bun.write(join(root, name), 'original bytes') + const response = await attach(name) + expect(response.status).toBe(200) + const info = (await response.json()) as UploadInfo + expect(info.filename).toBe(name) + expect(info.kind).toBe('file') + expect(info.mediaType).not.toBe('application/octet-stream') + await Bun.write(join(root, name), 'changed') + await rm(join(root, name)) + const stored = getUpload(workspaceId, info.id)! + expect(await Bun.file(stored.path!).text()).toBe('original bytes') + expect(getUpload('another-workspace', info.id)).toBeNull() + } +}) + +test('processes workspace images and allows internal visible symlinks', async () => { + await Bun.write( + join(root, 'image.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ) + ) + await symlink(join(root, 'image.png'), join(root, 'alias.png')) + const response = await attach('alias.png') + expect(response.status).toBe(200) + const info = (await response.json()) as UploadInfo + expect(info).toMatchObject({ kind: 'image', mediaType: 'image/png', width: 1, height: 1 }) + expect(getUpload(workspaceId, info.id)?.data).toBeDefined() +}) + +test('rejects unsafe paths, missing files, directories, symlink escapes and hidden targets', async () => { + await Bun.write(join(temp, 'outside.txt'), 'outside') + await Bun.write(join(root, '.secret'), 'hidden') + await mkdir(join(root, 'folder')) + await symlink(join(temp, 'outside.txt'), join(root, 'escape.txt')) + await symlink(join(root, '.secret'), join(root, 'hidden.txt')) + for (const path of [ + null, + 5, + '', + '/etc/passwd', + 'C:\\file.txt', + '../outside.txt', + 'folder/../x', + '.secret', + '.moi/data.json', + 'a/.hidden/b', + 'a\0b', + 'missing.txt', + 'folder', + 'escape.txt', + 'hidden.txt' + ]) { + expect((await attach(path)).status).toBe(400) + } +}) + +test('rejects oversized files before reading their bytes and invalid images during processing', async () => { + const path = join(root, 'large.bin') + await Bun.write(path, '') + await truncate(path, MAX_UPLOAD_BYTES + 1) + expect(await (await attach('large.bin')).text()).toContain('max 32 MB') + await Bun.write(join(root, 'bad.png'), 'not an image') + expect((await attach('bad.png')).status).toBe(400) +}) diff --git a/server/api-view-builders.test.ts b/server/api-view-builders.test.ts index 8202b6ee..cf48b200 100644 --- a/server/api-view-builders.test.ts +++ b/server/api-view-builders.test.ts @@ -90,7 +90,7 @@ describe('view builder sketch submission', () => { }) const draft = (await created.json()) as ViewBuilder const attachments: NonNullable = [ - { type: 'context', source: 'view:orders', label: 'Order', context: { id: '1042' } } + { type: 'text', source: 'view:orders', label: 'Order', text: 'Order ID: 1042' } ] if (withUpload) { for (const filename of ['requirements.txt', 'notes.txt']) { diff --git a/server/api.ts b/server/api.ts index bfd6f6c5..02a693c8 100644 --- a/server/api.ts +++ b/server/api.ts @@ -65,7 +65,7 @@ import type { SessionConfigPatch } from './session-config' import { DIST_DIR, prebuilt } from './static' import { getWorkspaceSkillsStatus, updateWorkspaceSkills } from './skill-update' import { serveWorkspaceImagePreview } from './preview' -import { MAX_UPLOAD_BYTES, addUpload, getUpload } from './uploads' +import { MAX_UPLOAD_BYTES, addUpload, addWorkspaceFileUpload, getUpload } from './uploads' import { requiredEnvFor } from './required-env' import { deleteView, @@ -501,6 +501,25 @@ one.post('/uploads', async c => { return c.json(out) }) +// Existing workspace documents enter the same upload pipeline without a browser round trip. +one.post('/uploads/from-path', async c => { + const body: unknown = await c.req.json().catch(() => null) + if (!body || typeof body !== 'object' || !('path' in body) || typeof body.path !== 'string') { + return c.text('Expected a workspace-relative path', 400) + } + const ws = c.get('ws') + try { + return c.json(await addWorkspaceFileUpload(ws.id, ws.path, body.path)) + } catch (error) { + if (error instanceof Error && 'code' in error) { + if (error.code === 'ENOENT') return c.text('File not found', 400) + if (error.code === 'EACCES' || error.code === 'EPERM') + return c.text('Cannot read this file', 400) + } + return c.text(error instanceof Error ? error.message : 'Failed to attach file', 400) + } +}) + // Serve an upload's bytes back. Display parts reference this URL instead of a // base64 data URL so the transcript broadcast / client cache stay small. The id // is content-addressed (sha256), so the response is immutable while it lives — diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index c08ba286..08a7269d 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -158,8 +158,8 @@ export function focusTab(tab, params) { bridge?.focusTab(tab, params); } -export function addChatContext(input) { - bridge?.addChatContext(input); +export function addChatAttachment(input) { + bridge?.addChatAttachment(input); } export function sendChatMessage(input, context) { diff --git a/server/harness/acp/adapter.test.ts b/server/harness/acp/adapter.test.ts index 3a2f2b97..7fee0567 100644 --- a/server/harness/acp/adapter.test.ts +++ b/server/harness/acp/adapter.test.ts @@ -267,7 +267,7 @@ describe('replayedUserParts', () => { const stored = appendMoiContext(noted, envelope) expect(replayedUserParts(stored)).toEqual([ { - type: 'file', + type: 'file-attachment', mediaType: 'application/octet-stream', url: '/tmp/up/report.pdf', filename: 'report.pdf' diff --git a/server/harness/acp/adapter.ts b/server/harness/acp/adapter.ts index 49d3fce8..7885318b 100644 --- a/server/harness/acp/adapter.ts +++ b/server/harness/acp/adapter.ts @@ -8,9 +8,9 @@ // the same item-per-turn model the Codex adapter produces. import { - splitContextAttachments, - contextAttachmentParts, - stripContextAttachmentsLoose + splitTextAttachments, + textAttachmentParts, + stripTextAttachmentsLoose } from '@/lib/moi-attachments' import { splitAttachmentNote } from '@/lib/attachment-note' import type { Part, ToolCall, ToolState, Turn, TurnMeta } from '@/lib/format' @@ -123,15 +123,15 @@ export function acpUsageToTurnMeta(usage: Usage | null | undefined): TurnMeta['u // carries the envelope as its lone text) folds to no parts — the caller skips // the bubble entirely. export function replayedUserParts(raw: string): Part[] { - const attached = splitContextAttachments(stripMoiContext(raw)) + const attached = splitTextAttachments(stripMoiContext(raw)) const split = splitAttachmentNote(attached.text) const parts: Part[] = split.files.map(f => ({ - type: 'file', + type: 'file-attachment', mediaType: 'application/octet-stream', url: f.path, filename: f.filename })) - parts.push(...contextAttachmentParts(attached.attachments)) + parts.push(...textAttachmentParts(attached.attachments)) if (split.text.trim()) parts.push({ type: 'text', text: split.text }) return parts } @@ -140,7 +140,7 @@ export function acpSessionToSessionInfo(entry: AcpSessionListEntry): SessionInfo const updated = entry.updatedAt ? Date.parse(entry.updatedAt) : NaN return { sessionId: entry.sessionId, - summary: stripContextAttachmentsLoose(entry.title?.trim() ?? '') || 'Untitled session', + summary: stripTextAttachmentsLoose(entry.title?.trim() ?? '') || 'Untitled session', lastModified: Number.isNaN(updated) ? 0 : updated, ...(entry.cwd ? { cwd: entry.cwd } : {}) } diff --git a/server/harness/acp/session.test.ts b/server/harness/acp/session.test.ts index 3f6ff3f2..016131c3 100644 --- a/server/harness/acp/session.test.ts +++ b/server/harness/acp/session.test.ts @@ -1,4 +1,4 @@ -import { splitContextAttachments } from '@/lib/moi-attachments' +import { splitTextAttachments } from '@/lib/moi-attachments' // Tests for the ACP session layer against a mock ACP agent (a tiny // newline-JSON-RPC script), exercising the REAL client transport + session // record + adapter — everything except a live backend. Covers the replay path @@ -299,7 +299,7 @@ describe('ACP session replay', () => { const turns = events.flatMap(e => (e.kind === 'turn' ? [e.turn] : [])) expect(turns.map(t => t.role)).toEqual(['user', 'assistant']) expect(turns[0].parts).toEqual([ - { type: 'file', mediaType: 'image/png', url: 'data:image/png;base64,aGVsbG8=' } + { type: 'file-attachment', mediaType: 'image/png', url: 'data:image/png;base64,aGVsbG8=' } ]) }) }) @@ -377,19 +377,19 @@ describe('ACP model state', () => { }) }) -test('context-only sends reach the ACP prompt and retain display parts', async () => { +test('text-attachment-only sends reach the ACP prompt and retain display parts', async () => { const agent = await mockAgent() - const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + const textAttachments = [{ source: 'view:orders', label: 'Order', text: 'Order ID: 1' }] await sendAcpMessage(agent.config, { ...agent.ctx, sessionId: agent.sessionId, isNew: false, content: '', - attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })) + attachments: textAttachments.map(a => ({ type: 'text' as const, ...a })) }) const prompt = (await agent.calls()).find(call => call.method === 'session/prompt')! const params = prompt.params as { prompt: Array<{ type: string; text?: string }> } expect( - splitContextAttachments(params.prompt.find(b => b.type === 'text')!.text!).attachments - ).toEqual(contextAttachments) + splitTextAttachments(params.prompt.find(b => b.type === 'text')!.text!).attachments + ).toEqual(textAttachments) }) diff --git a/server/harness/acp/session.ts b/server/harness/acp/session.ts index f7f6f0b9..e926589b 100644 --- a/server/harness/acp/session.ts +++ b/server/harness/acp/session.ts @@ -20,8 +20,8 @@ import type { MessageAttachment } from '@/lib/types' // - `session/prompt` is a long-running request that resolves at end of turn, // so its promise IS the turn's lifetime — activity is mirrored from it, // never derived by counting frames. -import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' -import type { ContextAttachment } from '@/lib/types' +import { appendTextAttachments, textAttachmentParts } from '@/lib/moi-attachments' +import type { TextAttachment } from '@/lib/types' import { appendAttachmentNote } from '@/lib/attachment-note' import { type MoiContext, appendMoiContext, renderMoiContext } from '@/lib/moi-context' import { type Part, applyEvent, emptyViewState } from '@/lib/format' @@ -312,7 +312,7 @@ function handleSessionUpdate(rec: SessionRecord, update: SessionUpdate, provider // Rebuilt as a data URL — the cold-reload fallback (live bubbles point // at moi's served upload URL, but that store is gone after a restart). rec.userImageParts.push({ - type: 'file', + type: 'file-attachment', mediaType: content.mimeType, url: `data:${content.mimeType};base64,${content.data}` }) @@ -500,16 +500,16 @@ async function buildPrompt( text: string, uploads: StoredUpload[], supportsImages: boolean, - contextAttachments: readonly ContextAttachment[] = [] + textAttachments: readonly TextAttachment[] = [] ): Promise<{ blocks: AcpPromptBlock[]; parts: Part[] }> { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } - parts.push(...contextAttachmentParts(contextAttachments)) + parts.push(...textAttachmentParts(textAttachments)) if (text) parts.push({ type: 'text', text }) - text = appendContextAttachments(text, contextAttachments) + text = appendTextAttachments(text, textAttachments) const blocks: AcpPromptBlock[] = [] const files: { filename: string; path: string }[] = [] @@ -596,14 +596,14 @@ export async function sendAcpMessage( context?: MoiContext } ): Promise { - const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const { uploadIds, textAttachments } = partitionMessageAttachments(input.attachments) const uploads = resolveUploads(input.workspaceId, uploadIds) - if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return + if (!input.content && uploads.length === 0 && textAttachments.length === 0) return const { blocks, parts } = await buildPrompt( input.content, uploads, config.supportsImages !== false, - contextAttachments + textAttachments ) if (blocks.length === 0) return diff --git a/server/harness/claude-code/NOTES.md b/server/harness/claude-code/NOTES.md index 58518ddf..c03812b0 100644 --- a/server/harness/claude-code/NOTES.md +++ b/server/harness/claude-code/NOTES.md @@ -504,7 +504,7 @@ type Part = | { type: 'text'; text: string; citations?: Citation[] } | { type: 'reasoning'; text: string; redacted?: boolean; signature?: string } | { type: 'tool-call'; call: ToolCall } - | { type: 'file'; mediaType: string; url: string; filename?: string } + | { type: 'file-attachment'; mediaType: string; url: string; filename?: string } | { type: 'source-url'; url: string; title?: string; sourceId: string } | { type: 'source-document'; mediaType: string; title: string; sourceId: string } | { type: 'data'; name: string; data: unknown } // escape hatch diff --git a/server/harness/claude-code/adapter.ts b/server/harness/claude-code/adapter.ts index da614f26..281f4c6f 100644 --- a/server/harness/claude-code/adapter.ts +++ b/server/harness/claude-code/adapter.ts @@ -1,4 +1,4 @@ -import { splitContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import { splitTextAttachments, textAttachmentParts } from '@/lib/moi-attachments' import type { AdapterEmit, Part, @@ -550,8 +550,8 @@ export class ClaudeAdapter { // SDK persists that appended text, so fold the note back into // file chips here — a reloaded bubble matches the live one // instead of leaking temp paths into it. - const attached = splitContextAttachments(text) - parts.push(...contextAttachmentParts(attached.attachments)) + const attached = splitTextAttachments(text) + parts.push(...textAttachmentParts(attached.attachments)) const split = splitAttachmentNote(attached.text) // moi's own context envelope strips precisely (marker-guarded); // other embedded machinery — system reminders, hook output — @@ -559,7 +559,7 @@ export class ClaudeAdapter { text = filterSystemText(stripMoiContext(split.text)).text for (const f of split.files) { parts.push({ - type: 'file', + type: 'file-attachment', mediaType: 'application/octet-stream', url: f.path, filename: f.filename @@ -567,7 +567,7 @@ export class ClaudeAdapter { } // An attachment-only message carries a synthesized placeholder // prompt; the bubble should show just the attachments. - if (isAttachmentOnlyPlaceholder(text) && parts.some(p => p.type === 'file')) { + if (isAttachmentOnlyPlaceholder(text) && parts.some(p => p.type === 'file-attachment')) { text = '' } } @@ -622,7 +622,7 @@ export class ClaudeAdapter { } } if (url) { - parts.push({ type: 'file', mediaType, url, filename: b.filename }) + parts.push({ type: 'file-attachment', mediaType, url, filename: b.filename }) } break } diff --git a/server/harness/claude-code/session.test.ts b/server/harness/claude-code/session.test.ts index a034d479..851898df 100644 --- a/server/harness/claude-code/session.test.ts +++ b/server/harness/claude-code/session.test.ts @@ -1,4 +1,4 @@ -import { splitContextAttachments } from '@/lib/moi-attachments' +import { splitTextAttachments } from '@/lib/moi-attachments' import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import * as sdk from '@anthropic-ai/claude-agent-sdk' import type { Options, Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' @@ -680,19 +680,19 @@ describe('Claude session message queue', () => { }, 7000) }) -test('context-only sends reach Claude and publish a labelled chip', async () => { - const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] +test('text-attachment-only sends reach Claude and publish a labelled chip', async () => { + const textAttachments = [{ source: 'view:orders', label: 'Order', text: 'Order ID: 1' }] await send({ content: '', - attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })) + attachments: textAttachments.map(a => ({ type: 'text' as const, ...a })) }) const content = drivers[0]!.inputs[0].message.content expect(typeof content).toBe('string') - expect(splitContextAttachments(content as string).attachments).toEqual(contextAttachments) + expect(splitTextAttachments(content as string).attachments).toEqual(textAttachments) expect(frames).toContainEqual( expect.objectContaining({ kind: 'turn', - turn: expect.objectContaining({ parts: [{ type: 'context', ...contextAttachments[0] }] }) + turn: expect.objectContaining({ parts: [{ type: 'text-attachment', ...textAttachments[0] }] }) }) ) }) diff --git a/server/harness/claude-code/session.ts b/server/harness/claude-code/session.ts index 8e3f385c..40bdeecd 100644 --- a/server/harness/claude-code/session.ts +++ b/server/harness/claude-code/session.ts @@ -7,8 +7,8 @@ import { partitionMessageAttachments } from '@/lib/message-attachments' // // The SDK persists history to disk. After eviction or a server restart, the // next message resumes that history in a new process. -import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' -import type { ContextAttachment } from '@/lib/types' +import { appendTextAttachments, textAttachmentParts } from '@/lib/moi-attachments' +import type { TextAttachment } from '@/lib/types' import { type Options, type Query, @@ -52,16 +52,16 @@ type MessageContent = SDKUserMessage['message']['content'] export function buildUserMessage( text: string, uploads: StoredUpload[], - contextAttachments: readonly ContextAttachment[] = [] + textAttachments: readonly TextAttachment[] = [] ): { content: MessageContent; parts: Part[] } { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } - parts.push(...contextAttachmentParts(contextAttachments)) + parts.push(...textAttachmentParts(textAttachments)) if (text) parts.push({ type: 'text', text }) - text = appendContextAttachments(text, contextAttachments) + text = appendTextAttachments(text, textAttachments) if (uploads.length === 0) return { content: text, parts } @@ -717,14 +717,10 @@ function createLiveSession(input: { // turn starts. The returned promise settles when the message reaches the SDK. export async function sendCCMessage(input: SendMessageInput): Promise { // Expired uploads are omitted. Avoid starting a session if nothing remains. - const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const { uploadIds, textAttachments } = partitionMessageAttachments(input.attachments) const uploads = resolveUploads(input.workspaceId, uploadIds) - if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return - const { content: userContent, parts } = buildUserMessage( - input.content, - uploads, - contextAttachments - ) + if (!input.content && uploads.length === 0 && textAttachments.length === 0) return + const { content: userContent, parts } = buildUserMessage(input.content, uploads, textAttachments) // Keep context in its own block: the SDK skips tag-leading blocks when // extracting titles and previews, and the adapter strips them on replay. const content: MessageContent = input.context @@ -766,7 +762,7 @@ export async function sendCCMessage(input: SendMessageInput): Promise { }) const label = input.content || - [...uploads.map(u => u.filename), ...contextAttachments.map(a => a.label)].join(', ') + [...uploads.map(u => u.filename), ...textAttachments.map(a => a.label)].join(', ') const completion = Promise.withResolvers() const accepted = new Promise((resolve, reject) => { messages.pending.push({ @@ -774,7 +770,7 @@ export async function sendCCMessage(input: SendMessageInput): Promise { content, titleSource: buildSessionTitleSource(input.content, [ ...uploads.map(u => u.filename), - ...contextAttachments.map(a => a.label) + ...textAttachments.map(a => a.label) ]), turnId, wireId: crypto.randomUUID(), diff --git a/server/harness/claude-code/sessions.ts b/server/harness/claude-code/sessions.ts index 13b4a654..d3dd3acb 100644 --- a/server/harness/claude-code/sessions.ts +++ b/server/harness/claude-code/sessions.ts @@ -1,6 +1,6 @@ // Session discovery + history replay for Claude Code workspaces, backed by // the Agent SDK's persisted `.jsonl` session files. -import { splitContextAttachments, stripContextAttachmentsLoose } from '@/lib/moi-attachments' +import { splitTextAttachments, stripTextAttachmentsLoose } from '@/lib/moi-attachments' import { getSessionInfo, getSessionMessages, @@ -36,13 +36,13 @@ export function claudeSessionSummary( session: Pick ): string { if (session.customTitle || !session.firstPrompt || session.summary !== session.firstPrompt) { - return stripContextAttachmentsLoose(session.summary) + return stripTextAttachmentsLoose(session.summary) } - const attached = splitContextAttachments(session.firstPrompt) + const attached = splitTextAttachments(session.firstPrompt) const split = splitAttachmentNote(attached.text) return isAttachmentOnlyPlaceholder(split.text) ? attachmentOnlyFilenames(split.text).join(', ') - : stripContextAttachmentsLoose(split.text) || attached.attachments.map(a => a.label).join(', ') + : stripTextAttachmentsLoose(split.text) || attached.attachments.map(a => a.label).join(', ') } export function visibleClaudeSessions>(sessions: T[]): T[] { @@ -69,7 +69,7 @@ export function selectOldestSessionFirstUserMessage( (a.createdAt ?? a.lastModified) - (b.createdAt ?? b.lastModified) || a.sessionId.localeCompare(b.sessionId) )[0] - return oldest?.firstPrompt ? stripContextAttachmentsLoose(oldest.firstPrompt) : undefined + return oldest?.firstPrompt ? stripTextAttachmentsLoose(oldest.firstPrompt) : undefined } export function selectLatestSessionUpdatedAt( diff --git a/server/harness/codex/adapter.ts b/server/harness/codex/adapter.ts index 95a681c1..4049e7cb 100644 --- a/server/harness/codex/adapter.ts +++ b/server/harness/codex/adapter.ts @@ -2,9 +2,9 @@ // tool items carry their native lifecycle through a tool-call part. The wire // types are a defensive subset; see NOTES.md for maintenance. import { - splitContextAttachments, - contextAttachmentParts, - stripContextAttachmentsLoose + splitTextAttachments, + textAttachmentParts, + stripTextAttachmentsLoose } from '@/lib/moi-attachments' import type { Part, @@ -143,7 +143,7 @@ export function codexServiceTierForFastMode( // Legacy context can be cut mid-envelope in a thread/list preview. function cleanPreview(preview: string | undefined): string { - return preview ? stripContextAttachmentsLoose(stripMoiContextLoose(preview)).trim() : '' + return preview ? stripTextAttachmentsLoose(stripMoiContextLoose(preview)).trim() : '' } export function codexThreadToSessionInfo(t: CodexThread): SessionInfo { @@ -289,12 +289,12 @@ function userInputToParts(content: CodexUserInput[] | undefined): Part[] { for (const c of content ?? []) { // Strip context from older sends that used the text-envelope fallback. if (c.type === 'text' && c.text) { - const attached = splitContextAttachments(stripMoiContext(c.text)) - parts.push(...contextAttachmentParts(attached.attachments)) + const attached = splitTextAttachments(stripMoiContext(c.text)) + parts.push(...textAttachmentParts(attached.attachments)) const text = attached.text if (text) parts.push({ type: 'text', text }) } else if (c.type === 'image' && c.url) - parts.push({ type: 'file', mediaType: 'image/*', url: c.url }) + parts.push({ type: 'file-attachment', mediaType: 'image/*', url: c.url }) else if (c.type === 'localImage' && c.path) parts.push({ type: 'text', text: `[image: ${c.path}]` }) } diff --git a/server/harness/codex/session.test.ts b/server/harness/codex/session.test.ts index 88dceb6b..0173ebfc 100644 --- a/server/harness/codex/session.test.ts +++ b/server/harness/codex/session.test.ts @@ -1,4 +1,4 @@ -import { splitContextAttachments } from '@/lib/moi-attachments' +import { splitTextAttachments } from '@/lib/moi-attachments' import { afterEach, describe, expect, spyOn, test } from 'bun:test' import type { BroadcastFrame, StreamEvent } from '@/lib/types' @@ -523,22 +523,22 @@ describe('Codex live session lifecycle', () => { }) for (const native of [true, false]) { - test(`context-only sends are durable with native context ${native}`, async () => { + test(`text-attachment-only sends are durable with native context ${native}`, async () => { const f = fixture() f.client.supportsAdditionalContext = native - const contextAttachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + const textAttachments = [{ source: 'view:orders', label: 'Order', text: 'Order ID: 1' }] await f.send('', { - attachments: contextAttachments.map(a => ({ type: 'context' as const, ...a })), + attachments: textAttachments.map(a => ({ type: 'text' as const, ...a })), optimisticId: 'context-turn', context: { activeTab: 'view:orders' } }) const start = f.calls.find(call => call.method === 'turn/start')! const sent = start.params.input as Array<{ type: string; text?: string }> const raw = sent.find(item => item.type === 'text')!.text! - expect(splitContextAttachments(raw).attachments).toEqual(contextAttachments) + expect(splitTextAttachments(raw).attachments).toEqual(textAttachments) const event = f.events().find(event => event.kind === 'turn' && event.turn.role === 'user') expect(event?.kind === 'turn' ? event.turn.parts : null).toEqual([ - { type: 'context', ...contextAttachments[0] } + { type: 'text-attachment', ...textAttachments[0] } ]) }) } diff --git a/server/harness/codex/session.ts b/server/harness/codex/session.ts index 5c4373b1..09144fe1 100644 --- a/server/harness/codex/session.ts +++ b/server/harness/codex/session.ts @@ -3,8 +3,8 @@ import type { MessageAttachment } from '@/lib/types' // Live state per (workspaceId, sessionId): display history, native lifecycle, // and previews. client.ts owns the shared workspace process; // Codex owns durable history. See NOTES.md for ordering and recovery rules. -import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' -import type { ContextAttachment } from '@/lib/types' +import { appendTextAttachments, textAttachmentParts } from '@/lib/moi-attachments' +import type { TextAttachment } from '@/lib/types' import { appendAttachmentNote } from '@/lib/attachment-note' import { buildSessionTitleSource } from '../session-title' import { @@ -894,16 +894,16 @@ async function resumeSession(input: ResumeInput): Promise { async function buildUserInput( text: string, uploads: StoredUpload[], - contextAttachments: readonly ContextAttachment[] = [] + textAttachments: readonly TextAttachment[] = [] ): Promise<{ input: CodexUserInputItem[]; parts: Part[] }> { const parts: Part[] = [] for (const u of uploads) { const part = uploadToDisplayPart(u) if (part) parts.push(part) } - parts.push(...contextAttachmentParts(contextAttachments)) + parts.push(...textAttachmentParts(textAttachments)) if (text) parts.push({ type: 'text', text }) - text = appendContextAttachments(text, contextAttachments) + text = appendTextAttachments(text, textAttachments) const input: CodexUserInputItem[] = [] for (const u of uploads) { @@ -956,20 +956,16 @@ async function sendMessage( lane: SendLane, generation: number ): Promise { - const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const { uploadIds, textAttachments } = partitionMessageAttachments(input.attachments) const uploads = resolveUploads(input.workspaceId, uploadIds) - if (!input.content && uploads.length === 0 && contextAttachments.length === 0) return + if (!input.content && uploads.length === 0 && textAttachments.length === 0) return const sessionTitleSource = input.isNew ? buildSessionTitleSource(input.content, [ ...uploads.map(upload => upload.filename), - ...contextAttachments.map(a => a.label) + ...textAttachments.map(a => a.label) ]) : undefined - const { input: userInput, parts } = await buildUserInput( - input.content, - uploads, - contextAttachments - ) + const { input: userInput, parts } = await buildUserInput(input.content, uploads, textAttachments) if (userInput.length === 0) return const serviceTier = codexServiceTierForFastMode(input.fastMode) diff --git a/server/harness/openclaw/adapter.ts b/server/harness/openclaw/adapter.ts index 0f2dfdd7..d8c0f85e 100644 --- a/server/harness/openclaw/adapter.ts +++ b/server/harness/openclaw/adapter.ts @@ -9,9 +9,9 @@ // `toStreamEvents` is the cold path (REST replay); `messageToTurn` is the // per-frame path the live session uses. import { - splitContextAttachments, - contextAttachmentParts, - stripContextAttachmentsLoose + splitTextAttachments, + textAttachmentParts, + stripTextAttachmentsLoose } from '@/lib/moi-attachments' import type { Part, StreamEvent, ToolCall, ToolState, Turn, TurnMeta } from '@/lib/format' import type { SessionInfo } from '@/lib/types' @@ -34,7 +34,7 @@ import { stripSubagentEnvelope, stripUserMessageMetadata } from './strip' // A no-op on channel/cron labels that never contain it. function cleanTitle(text: string | undefined): string { return stripSubagentEnvelope( - stripContextAttachmentsLoose(stripMoiContextLoose(text?.trim() ?? '')) + stripTextAttachmentsLoose(stripMoiContextLoose(text?.trim() ?? '')) ).trim() } @@ -234,9 +234,9 @@ export function messageToTurn( const parts = blocks .flatMap(b => { if (msg.role === 'user' && b.type === 'text' && typeof b.text === 'string') { - const attached = splitContextAttachments(stripUserMessageMetadata(b.text)) + const attached = splitTextAttachments(stripUserMessageMetadata(b.text)) return [ - ...contextAttachmentParts(attached.attachments), + ...textAttachmentParts(attached.attachments), blockToPart({ ...b, text: attached.text }, msg.role, results, omitToolCallIds) ] } diff --git a/server/harness/openclaw/live-frames.test.ts b/server/harness/openclaw/live-frames.test.ts index 2ff06e97..a6ffe406 100644 --- a/server/harness/openclaw/live-frames.test.ts +++ b/server/harness/openclaw/live-frames.test.ts @@ -1,4 +1,4 @@ -import { appendContextAttachments } from '@/lib/moi-attachments' +import { appendTextAttachments } from '@/lib/moi-attachments' // Wire-fixture tests for the live protocol-v4 layer: chat delta previews, // session.tool result flattening, and durable-row → Turn mapping. Fixtures are // real frames captured from live gateways (2026.7.1 events-v2/events-tool runs, @@ -317,32 +317,30 @@ describe('claimPreviewSource', () => { }) }) -test('context-only echo keys are non-empty and distinguish attached records', () => { - const first = { source: 'view:orders', label: 'Order', context: { id: '1' } } - const wire = appendContextAttachments('', [first]) +test('text-attachment-only echo keys are non-empty and distinguish attached records', () => { + const first = { source: 'view:orders', label: 'Order', text: 'Order ID: 1' } + const wire = appendTextAttachments('', [first]) expect(normalizeEchoText(wire)).not.toBe('') expect(normalizeEchoText(wire)).toBe( normalizeEchoText( - appendContextAttachments('', [ - { context: first.context, label: first.label, source: first.source } - ]) + appendTextAttachments('', [{ text: first.text, label: first.label, source: first.source }]) ) ) expect(normalizeEchoText(wire)).not.toBe( - normalizeEchoText(appendContextAttachments('', [{ ...first, context: { id: '2' } }])) + normalizeEchoText(appendTextAttachments('', [{ ...first, text: 'Order ID: 2' }])) ) }) test('parsed user turns match serialized echo keys with and without message text', () => { - const attachments = [{ source: 'view:orders', label: 'Order', context: { id: '1' } }] + const attachments = [{ source: 'view:orders', label: 'Order', text: 'Order ID: 1' }] for (const text of ['', 'Review this\norder']) { - const wire = appendContextAttachments(text, attachments) + const wire = appendTextAttachments(text, attachments) const turn = messageToTurn({ role: 'user', content: wire }, 'test-session', 0, new Map()) expect(turn).not.toBeNull() expect( userEchoKey( turn!.parts.find(p => p.type === 'text')?.text ?? '', - turn!.parts.filter(p => p.type === 'context') + turn!.parts.filter(p => p.type === 'text-attachment') ) ).toBe(normalizeEchoText(wire)) } diff --git a/server/harness/openclaw/session.ts b/server/harness/openclaw/session.ts index d8440dfc..7a24c0ce 100644 --- a/server/harness/openclaw/session.ts +++ b/server/harness/openclaw/session.ts @@ -1,5 +1,5 @@ import { partitionMessageAttachments } from '@/lib/message-attachments' -import type { ContextAttachment, MessageAttachment } from '@/lib/types' +import type { TextAttachment, MessageAttachment } from '@/lib/types' // Per-(workspaceId, sessionId) live OpenClaw session. // // Holds the in-memory view for one session and keeps it current from the @@ -13,9 +13,9 @@ import type { ContextAttachment, MessageAttachment } from '@/lib/types' // §6; the rules that follow from it (who owns a tool card, why identity is // decided once) are worth reading before changing anything here. import { - appendContextAttachments, - splitContextAttachments, - contextAttachmentParts + appendTextAttachments, + splitTextAttachments, + textAttachmentParts } from '@/lib/moi-attachments' import { appendAttachmentNote } from '@/lib/attachment-note' import { @@ -497,16 +497,16 @@ function previewMessageId(sessionKey: string, runId: string): string { // between what we sent and what the gateway stored can't defeat the match. export function normalizeEchoText(text: string | undefined): string { if (typeof text !== 'string') return '' - const attached = splitContextAttachments(stripMoiContext(text)) + const attached = splitTextAttachments(stripMoiContext(text)) return userEchoKey(attached.text, attached.attachments) } -export function userEchoKey(text: string, attachments: readonly ContextAttachment[] = []): string { +export function userEchoKey(text: string, attachments: readonly TextAttachment[] = []): string { const visible = text.replace(/\s+/g, ' ').trim() - // Include attachments in the fallback key: context-only messages have no + // Include attachments in the fallback key: text-attachment-only messages have no // visible text, and two identical prompts may refer to different records. return attachments.length - ? `${visible}\n${JSON.stringify(contextAttachmentParts(attachments))}` + ? `${visible}\n${JSON.stringify(textAttachmentParts(attachments))}` : visible } @@ -545,7 +545,7 @@ function emitTurn(rec: SessionRecord, msg: OpenClawMessage, idx: number): void { if (at < 0) { const text = userEchoKey( turn.parts.find(p => p.type === 'text')?.text ?? '', - turn.parts.filter(p => p.type === 'context') + turn.parts.filter(p => p.type === 'text-attachment') ) if (text) at = rec.pendingUserEchoes.findIndex(e => normalizeEchoText(e.text) === text) } @@ -1250,9 +1250,9 @@ export async function sendOpenClawMessage(input: { context?: MoiContext }): Promise { // Fold any attachments into the message text as file-path references. - const { uploadIds, contextAttachments } = partitionMessageAttachments(input.attachments) + const { uploadIds, textAttachments } = partitionMessageAttachments(input.attachments) const uploads = resolveUploads(input.workspaceId, uploadIds) - let content = appendContextAttachments(input.content, contextAttachments) + let content = appendTextAttachments(input.content, textAttachments) if (uploads.length > 0) { const files: { filename: string; path: string }[] = [] for (const u of uploads) { diff --git a/server/moi-scaffold.ts b/server/moi-scaffold.ts index 87aceaef..9bffd870 100644 --- a/server/moi-scaffold.ts +++ b/server/moi-scaffold.ts @@ -102,8 +102,12 @@ declare module 'moi' { // target view as its \`params\` prop — JSON-plain values only. No-ops outside // the moi host. Tab ids: 'overview' | 'agent' | 'scratchpad' | 'view:'. export function focusTab(tab: string, params?: Record): void - // Stage context in the current chat draft; does not send a message. - export function addChatContext(input: { label: string; context: Record }): void + export type ChatAttachmentInput = + | { type: 'text'; label: string; text: string } + | { type: 'file'; file: File; path?: never } + | { type: 'file'; path: string; file?: never } + // Stage an attachment in the current chat draft; does not send a message. + export function addChatAttachment(input: ChatAttachmentInput): void // Send a chat message to the workspace's active chat, as if the user typed // \`message\`. \`context\` rides along as structured data the agent sees but // the user does not — JSON-plain values only. Call it from event handlers, diff --git a/server/test/__fixtures__/with-chatattachment.tsx b/server/test/__fixtures__/with-chatattachment.tsx new file mode 100644 index 00000000..0ac224ee --- /dev/null +++ b/server/test/__fixtures__/with-chatattachment.tsx @@ -0,0 +1,5 @@ +import { addChatAttachment } from 'moi' + +export default function AttachmentButton() { + return +} diff --git a/server/test/__fixtures__/with-chatcontext.tsx b/server/test/__fixtures__/with-chatcontext.tsx deleted file mode 100644 index 37845170..00000000 --- a/server/test/__fixtures__/with-chatcontext.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { addChatContext } from 'moi' - -export default function ContextButton() { - return -} diff --git a/server/test/attachments.test.ts b/server/test/attachments.test.ts index f36c1df3..e92b4749 100644 --- a/server/test/attachments.test.ts +++ b/server/test/attachments.test.ts @@ -36,7 +36,7 @@ async function addImage(workspaceId: string, name = 'shot.png') { } function fileParts(parts: Part[]) { - return parts.filter((p): p is Extract => p.type === 'file') + return parts.filter((p): p is Extract => p.type === 'file-attachment') } function textPart(parts: Part[]) { return parts.find((p): p is Extract => p.type === 'text') @@ -275,8 +275,8 @@ describe('uploads: resolve + display helpers', () => { test('uploadToDisplayPart points at the served URL, never a data URL', async () => { const img = await addImage('wsp') const imgPart = uploadToDisplayPart(img) - expect(imgPart?.type).toBe('file') - if (imgPart?.type === 'file') { + expect(imgPart?.type).toBe('file-attachment') + if (imgPart?.type === 'file-attachment') { expect(imgPart.mediaType).toBe('image/png') expect(imgPart.url).toBe(`/api/workspaces/wsp/uploads/${img.id}`) } @@ -289,7 +289,7 @@ describe('uploads: resolve + display helpers', () => { }) const [file] = resolveUploads('wsp', [fileInfo.id]) const filePart = uploadToDisplayPart(file) - if (filePart?.type === 'file') { + if (filePart?.type === 'file-attachment') { expect(filePart.filename).toBe('doc.txt') expect(filePart.url).toBe(`/api/workspaces/wsp/uploads/${file.id}`) } @@ -340,7 +340,7 @@ describe('buildUserMessage', () => { expect(blocks[1].text).toBe('describe') // Display parts: attachment first, then text. - expect(parts[0].type).toBe('file') + expect(parts[0].type).toBe('file-attachment') expect(parts[1].type).toBe('text') }) diff --git a/server/test/build-applet.test.ts b/server/test/build-applet.test.ts index 6573ad50..4267076b 100644 --- a/server/test/build-applet.test.ts +++ b/server/test/build-applet.test.ts @@ -531,10 +531,10 @@ describe('moi fileUrl module', () => { expect(result.js).toContain('sendChatMessage(input, context)') }) - test('bundles addChatContext through the applet bridge', async () => { - const result = await buildApplet(join(FIXTURES, 'with-chatcontext.tsx'), undefined, 'view') - expect(result.js).toContain('function addChatContext') - expect(result.js).toContain('addChatContext(input)') + test('bundles addChatAttachment through the applet bridge', async () => { + const result = await buildApplet(join(FIXTURES, 'with-chatattachment.tsx'), undefined, 'view') + expect(result.js).toContain('function addChatAttachment') + expect(result.js).toContain('addChatAttachment(input)') }) test('every bundle entry exports the bridge wiring, even without a moi import', async () => { diff --git a/server/test/skills-template.test.ts b/server/test/skills-template.test.ts index ba92b081..29970e25 100644 --- a/server/test/skills-template.test.ts +++ b/server/test/skills-template.test.ts @@ -27,7 +27,7 @@ describe('installBundledSkills', () => { expect(skillMd).toContain('Standard UI components') expect(skillMd).toContain('references/INTENTS.md') const intents = await Bun.file(join(dir, 'moi-workspace', 'references', 'INTENTS.md')).text() - expect(intents).toContain('function addChatContext(input:') + expect(intents).toContain('function addChatAttachment(input:') expect(intents).toContain('function sendChatMessage(input:') expect(intents).toContain('function focusTab(tab:') expect(skillMd).toContain('moi ui-components add') diff --git a/server/test/context-attachment-replay.test.ts b/server/test/text-attachment-replay.test.ts similarity index 86% rename from server/test/context-attachment-replay.test.ts rename to server/test/text-attachment-replay.test.ts index 90388e29..6cf1950a 100644 --- a/server/test/context-attachment-replay.test.ts +++ b/server/test/text-attachment-replay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { appendContextAttachments, contextAttachmentParts } from '@/lib/moi-attachments' +import { appendTextAttachments, textAttachmentParts } from '@/lib/moi-attachments' import { appendMoiContext, renderMoiContext, moiContextSystemReminder } from '@/lib/moi-context' import { ClaudeAdapter } from '../harness/claude-code/adapter' import { codexItemToTurn } from '../harness/codex/adapter' @@ -7,15 +7,15 @@ import { messageToTurn } from '../harness/openclaw/adapter' import { replayedUserParts } from '../harness/acp/adapter' import { buildUserMessage } from '../harness/claude-code/session' -const attachments = [{ source: 'view:orders', label: 'Order #1042', context: { orderId: '1042' } }] +const attachments = [{ source: 'view:orders', label: 'Order #1042', text: 'Order ID: 1042' }] const ambient = renderMoiContext({ activeTab: 'view:orders' }) -describe('durable context attachments', () => { +describe('durable text attachments', () => { for (const text of ['Review this order', '', 'What does mean?']) { - test(`replays the same display parts across all harnesses: ${text || 'context only'}`, () => { - const raw = appendContextAttachments(text, attachments) + test(`replays the same display parts across all harnesses: ${text || 'attachment only'}`, () => { + const raw = appendTextAttachments(text, attachments) const expected = [ - ...contextAttachmentParts(attachments), + ...textAttachmentParts(attachments), ...(text ? [{ type: 'text' as const, text }] : []) ] const cc = new ClaudeAdapter() diff --git a/server/uploads.ts b/server/uploads.ts index 0a26c55d472f2b3cf71e2bc97aac265f67fa98fe..bec96bae6634e029f023bfbee6e4172ea1e4b76e 100644 GIT binary patch delta 1877 zcmbVNO>f&q5Ctlr2LsI^$iYcbbP5y|iO5vipcgd`Y9$V0M79j)t2r4fawKiN+GTf_ zmR;N6wYMJhI|2moq1PUJX;JhC6`fg9`JTZ+y4^a(6J?c)Z_#^J4S)^2l$y zN{+fSbWTigO)R&$^S@fZfAICm|Jd0+|Lwz{Za(dH;kg#Mn;H)S<_$6!ONAh{H{hoT zj%ozwjYTLNW-vO2ku`IN7K&UMk-5c|ZI2I87WAW;gblA;-64${S<`OQ>KS#9T;$g~ucePTStk@0hfO6riLxp> zfqsKg>YyrBoVZ*`ACYC^gbfw7y8#@D^k|CdLC~U3pM}iejp>0OO2DJs zIE1OpGSs0`2d1=uE)_W@|IH)e3!vdDl`L#^(uf`6WQGCPk|hv215^0$rh#BoR-$#6 zxDF0$xaP)-Di%7G7Tj{laDVUdaA)uaPTyPH89o^f2E*4^mUsGt;iG5FwztQ@FO9O) z!{vome0D-hKH}KgRs5Vt$Mr?md1&(}$JCSi1<5xll(2!>P*Rm5WN`9Fgojr;GKTVs zD+86%v4}wFCJ50?9KrpsV7njJc}e@eDTq<-Lz+w(;2~*BeZUKW#0j?|h_R=33=m+b zkv!n7picD&8G1rqk_Qgaj+L3S0~%&H!jUEW#dwT15lWdf+NQopX5_$&lJ3Hn*Gwo{ zt&O6n7eejpa4)2vPuVF^SbIP~N;)S|s3+8-wKffgP*>xHAP%oY0UC`^Vzd(~Y4*cf zy{HSNDhVqEcv}*-?J-sB>jWmIS%udo(4eemBhNIxmff?J@i^p*dEk#r51P+RHQt){ zGt8uTahy{?zt9PXR{@EqML0c$w#aFqq%>)a?wZ9Pt2cL{;*oN)?1?Kz!dh`0-K%J> vxIE(O0W))n delta 99 zcmewtcEMvq9`ocnX5Go>nH9yd@-y=kY8BFo@^cl`^YT+ttqKxLGB!7`oMqBX&d)0@ yQSfz)hz||$@pp8IcZv*g4OXyKFg8-qQZO_yGJ#T?o7g*qHWw>w=9+A%?F#_aR3IM! diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md index 5343adcc..6ce301e4 100644 --- a/workspace/.claude/skills/moi-workspace/references/INTENTS.md +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -56,48 +56,62 @@ export default function Orders({ params = {} }: { params?: Params }) { target view's source, mirror the shape you find there, and note where you read it. That file is the contract; the type is documentation, not a shared module. -## `addChatContext({ label, context })` +## `addChatAttachment(input)` Use this when the action may need the user's input or modification before sending. It adds -a removable chip to the current draft, opens chat, and focuses the composer, keeping existing -text intact. The user can write or revise their message, add more attachments, remove chips, -or send context on its own. +an attachment to the current draft, opens chat, and focuses the composer, keeping existing +text intact. The user can revise their message, add or remove attachments, or send attachments +on their own. Staging works while the agent is busy or unavailable. ### API ```ts -function addChatContext(input: { - label: string - context: Record -}): void +type ChatAttachmentInput = + | { type: 'text'; label: string; text: string } + | { type: 'file'; file: File; path?: never } + | { type: 'file'; path: string; file?: never } + +function addChatAttachment(input: ChatAttachmentInput): void ``` -- `label`: required non-empty label, up to 120 characters, shown on the attachment chip. -- `context`: required plain JSON object, up to 5,000 serialized characters. Include the data the - agent needs to understand the item. +- Text requires a non-empty `label` (up to 120 characters) and non-blank `text` (up to 5,000 + characters). Text is preserved exactly and can contain prose, Markdown, or formatted JSON. +- Files accept exactly one browser `File` or workspace-root-relative `path`. The filename is + the label. The existing upload limit is 32 MB; images use the existing image processing. +- Paths must point to regular files inside the workspace. Absolute paths, traversal, hidden + path segments, and symlinks escaping the workspace are rejected. -### Example +### Examples ```tsx -import { addChatContext } from 'moi' +import { addChatAttachment } from 'moi' - + text: 'Order: 1042\nStatus: delayed' +}) + +// Attach an existing workspace document. +addChatAttachment({ type: 'file', path: 'reports/september.pdf' }) + +// Attach a browser-generated file, including an exported drawing. +addChatAttachment({ type: 'file', file: generatedFile }) ``` ### Limits -Data is validated and copied at click time. The snapshot cannot be edited in the composer, -and later applet changes do not affect it. -Invalid data rejects the whole call. Identical source, label, and JSON payloads are skipped. -Context items can be staged alongside files and drawings. +Call from user event handlers. Text is captured immediately; workspace files are copied when +the server reads them during staging. Later changes do not update the attachment. Text appears +as a labelled chip; files use the existing file or image representation. + +Invalid arguments reject the call without staging anything. File preparation failures show a +removable error chip. File loading blocks sending until it finishes. Errors are recorded in +`moi debug logs`. -Staging works while the agent is busy or unavailable. Draft attachments stay with their chat -in memory and are lost on page reload. No file is uploaded. +Identical text from the same applet with the same label is skipped. Attachments stay with their +chat in memory and are lost on page reload. Text attachments have no preview or editing UI. ## `sendChatMessage({ message, context? })` From ab254a3f6867e7d0926f504de6fa0e4bf0f5e8ae Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 17:16:49 +0200 Subject: [PATCH 12/24] Validate applet chat attachments before sending --- .../features/applets/applet-runtime.test.ts | 103 +++++-- client/features/applets/applet-runtime.ts | 143 +++++---- .../features/chat/applet-chat-intents.test.ts | 272 +++++++++++++++++- client/features/chat/applet-chat-intents.ts | 145 ++++++++-- client/features/chat/chat-send.ts | 44 ++- .../composer/attachments/draft-attachments.ts | 7 +- .../chat/composer/attachments/uploads.ts | 13 + client/features/chat/useChat.ts | 15 +- client/features/workspace/WorkspaceScreen.tsx | 2 +- lib/moi-context.ts | 37 +-- server/applets/build-applet.ts | 7 +- server/moi-scaffold.ts | 10 +- .../__fixtures__/with-sendchatmessage.tsx | 2 +- server/test/build-applet.test.ts | 2 +- server/test/moi-context.test.ts | 18 +- .../moi-workspace/references/INTENTS.md | 39 ++- 16 files changed, 659 insertions(+), 200 deletions(-) diff --git a/client/features/applets/applet-runtime.test.ts b/client/features/applets/applet-runtime.test.ts index 9251ac36..63839af4 100644 --- a/client/features/applets/applet-runtime.test.ts +++ b/client/features/applets/applet-runtime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'bun:test' +import * as appletLog from './applet-log' +import { describe, expect, test, spyOn } from 'bun:test' import { appletKey, @@ -100,10 +101,17 @@ describe('sendChatMessage validation', () => { const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(WIDGET) - bridge.sendChatMessage({ message: ' Chase order A-1042 ', context: { order: 'A-1042' } }) + bridge.sendChatMessage({ + message: ' Chase order A-1042 ', + attachments: [{ type: 'text', label: 'Order', text: 'A-1042', source: 'forged' }] + }) expect(calls).toEqual([ - { message: 'Chase order A-1042', source: 'widget:clock', context: { order: 'A-1042' } } + { + message: 'Chase order A-1042', + source: 'widget:clock', + attachments: [{ type: 'text', label: 'Order', text: 'A-1042', source: 'widget:clock' }] + } ]) }) @@ -113,17 +121,27 @@ describe('sendChatMessage validation', () => { const { bridge } = appletRuntime(ws).connect(WIDGET) bridge.sendChatMessage(' Legacy ', { order: '1042' }) - bridge.sendChatMessage({ message: 'Legacy', context: { order: '1042' } }) + bridge.sendChatMessage({ message: 'Legacy', attachments: [] }) bridge.sendChatMessage('Message only') - bridge.sendChatMessage('Bad context', []) + bridge.sendChatMessage('Array context', []) bridge.sendChatMessage('x'.repeat(1001)) - bridge.sendChatMessage({ message: 'Object', context: { own: true } }, { ignored: true }) + bridge.sendChatMessage({ message: 'Object' }, { ignored: true }) expect(calls).toEqual([ - { message: 'Legacy', source: 'widget:clock', context: { order: '1042' } }, - { message: 'Message only', source: 'widget:clock', context: undefined }, - { message: 'Bad context', source: 'widget:clock', context: undefined }, - { message: 'Object', source: 'widget:clock', context: { own: true } } + { + message: 'Legacy', + source: 'widget:clock', + attachments: [ + { type: 'text', label: 'Context', text: '{"order":"1042"}', source: 'widget:clock' } + ] + }, + { message: 'Message only', source: 'widget:clock', attachments: [] }, + { + message: 'Array context', + source: 'widget:clock', + attachments: [{ type: 'text', label: 'Context', text: '[]', source: 'widget:clock' }] + }, + { message: 'Object', source: 'widget:clock', attachments: [] } ]) }) @@ -154,23 +172,68 @@ describe('sendChatMessage validation', () => { expect(calls).toEqual([]) }) - test('keeps the message but drops a context that cannot ride the envelope', () => { + test('legacy serialization failures reject the entire call', () => { const ws = `ws-${crypto.randomUUID()}` const { calls } = subscribeChat(ws) const { bridge } = appletRuntime(ws).connect(VIEW) - const cyclic: Record = {} cyclic.self = cyclic + for (const context of [cyclic, 1n, () => {}, { blob: 'x'.repeat(5000) }]) { + bridge.sendChatMessage('Invalid legacy', context) + } + expect(calls).toEqual([]) + // Rejected arguments do not consume the message cooldown. + bridge.sendChatMessage('Invalid legacy', 'x'.repeat(4998)) + expect(calls[0].attachments[0]).toMatchObject({ + label: 'Context', + text: JSON.stringify('x'.repeat(4998)) + }) + }) - bridge.sendChatMessage({ message: 'array context', context: ['not', 'a', 'record'] }) - bridge.sendChatMessage({ message: 'cyclic context', context: cyclic }) - bridge.sendChatMessage({ message: 'huge context', context: { blob: 'x'.repeat(2001) } }) + test('rejects the removed object context field with a clear error', () => { + const ws = `ws-${crypto.randomUUID()}` + const { calls } = subscribeChat(ws) + const { bridge } = appletRuntime(ws).connect(VIEW) + const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) + try { + bridge.sendChatMessage({ message: 'Review', context: undefined }) + expect(calls).toEqual([]) + expect(log.mock.calls[0][1].message).toContain('Use attachments instead') + } finally { + log.mockRestore() + } + }) - // The message carries the user's intent, so a bad payload must not lose it. - expect(calls.map(c => [c.message, c.context])).toEqual([ - ['array context', undefined], - ['cyclic context', undefined], - ['huge context', undefined] + test('validates all attachments before emitting and snapshots all input fields', () => { + const ws = `ws-${crypto.randomUUID()}` + const { calls } = subscribeChat(ws) + const { bridge } = appletRuntime(ws).connect(VIEW) + const file = new File(['hello'], 'hello.txt') + const text = { type: 'text', label: 'Order', text: ' snapshot ' } + const path = { type: 'file', path: 'reports/order.pdf' } + const attachments = [text, { type: 'file', file }, path] + for (const invalid of [ + null, + {}, + { type: 'text', label: '', text: 'x' }, + { type: 'text', label: 'x'.repeat(121), text: 'x' }, + { type: 'text', label: 'x', text: 'x'.repeat(5001) }, + { type: 'file', path: '../secret' }, + { type: 'file', path: 'x', file }, + { type: 'file', file: new File([new Uint8Array(32 * 1024 * 1024 + 1)], 'big') } + ]) { + bridge.sendChatMessage({ message: 'Review', attachments: [...attachments, invalid] }) + } + bridge.sendChatMessage({ message: 'Review', attachments: {} }) + expect(calls).toEqual([]) + bridge.sendChatMessage({ message: 'Review', attachments }) + text.text = 'changed' + path.path = 'changed.txt' + attachments.length = 0 + expect(calls[0].attachments).toEqual([ + { type: 'text', label: 'Order', text: ' snapshot ', source: 'view:board' }, + { type: 'file', file, source: 'view:board' }, + { type: 'file', path: 'reports/order.pdf', source: 'view:board' } ]) }) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index 46a3b20b..0ec0f88f 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -24,7 +24,6 @@ import { createNanoEvents } from 'nanoevents' import { reportAppletError } from '@/client/features/applets/applet-log' import { createRateLimiter } from '@/client/lib/rate-limit' import { useLatestRef } from '@/client/lib/use-latest-ref' -import { MAX_APPLET_CONTEXT_CHARS } from '@/lib/moi-context' import type { AppletKind, WorkspaceTabId } from '@/lib/types' import { isParamsRecord, isWorkspaceTabId } from '@/lib/workspace-tabs' @@ -39,7 +38,7 @@ export const appletSource = ({ kind, name }: AppletIdentity): string => `${kind} export type AppletChatMessage = { message: string source: string - context?: Record + attachments: AppletChatAttachment[] } export type AppletChatAttachment = ChatAttachmentInput & { source: string } @@ -52,7 +51,7 @@ export type AppletEvents = { // (history state is structured-cloned). focusTab: (tab: WorkspaceTabId, params?: Record) => void // A message for the workspace's active chat, sent as if the user typed - // `message`. `context` rides the `` envelope, not the bubble. + // `message`, with attachments prepared before the send. sendChatMessage: (message: AppletChatMessage) => void } @@ -133,32 +132,11 @@ function createRuntime(workspaceId: string) { const bridge: AppletBridge = { addChatAttachment(input) { if (!alive) return - if (isParamsRecord(input)) { - if (input.type === 'text') { - const snapshot = snapshotTextAttachment(input) - if (snapshot) { - emitter.emit('addChatAttachment', { type: 'text', ...snapshot, source }) - return - } - } else if (input.type === 'file') { - if ( - input.file instanceof File && - input.path === undefined && - input.file.size <= MAX_UPLOAD_BYTES - ) { - emitter.emit('addChatAttachment', { type: 'file', file: input.file, source }) - return - } - if (isWorkspaceAttachmentPath(input.path) && input.file === undefined) { - emitter.emit('addChatAttachment', { type: 'file', path: input.path, source }) - return - } - } + try { + emitter.emit('addChatAttachment', snapshotAttachmentInput(input, source)) + } catch (error) { + drop(identity, `addChatAttachment() was dropped: ${errorMessage(error)}`) } - drop( - identity, - 'addChatAttachment() was dropped: provide labelled text (label max 120 characters, text max 5000), or exactly one File (max 32 MB) or workspace-relative path. Hidden paths are not allowed.' - ) }, focusTab(tab, params) { if (!alive) return @@ -167,28 +145,41 @@ function createRuntime(workspaceId: string) { }, sendChatMessage(input, legacyContext) { if (!alive) return - // Older applets pass (message, context?). Normalize to the object form - // so both APIs share validation and rate limits. - if (typeof input === 'string') input = { message: input, context: legacyContext } - if (!isParamsRecord(input) || typeof input.message !== 'string') return - const { message, context } = input - const text = message.trim() - if (!text) return - if (text.length > MAX_MESSAGE_CHARS) { - drop( - identity, - `sendChatMessage() was dropped: the message is ${text.length} characters (max ${MAX_MESSAGE_CHARS}). Put long content in the context field, not the message.` + try { + // Runtime compatibility for older bundles. Both forms use the same + // attachment validation and send path; only the object API is public. + if (typeof input === 'string') { + input = { + message: input, + attachments: + legacyContext === undefined + ? [] + : [{ type: 'text', label: 'Context', text: JSON.stringify(legacyContext) }] + } + } + if (!isParamsRecord(input) || typeof input.message !== 'string') { + throw new Error('provide an object with a message string and optional attachments.') + } + if ('context' in input) { + throw new Error('the context field is no longer supported. Use attachments instead.') + } + const message = input.message.trim() + if (!message || message.length > MAX_MESSAGE_CHARS) { + throw new Error( + `message must be non-empty and at most ${MAX_MESSAGE_CHARS} characters. Put longer content in a text attachment.` + ) + } + if (input.attachments !== undefined && !Array.isArray(input.attachments)) { + throw new Error('attachments must be an array.') + } + const attachments = Array.from(input.attachments ?? [], item => + snapshotAttachmentInput(item, source) ) - return + if (!admitChatMessage(identity, source, message)) return + emitter.emit('sendChatMessage', { message, source, attachments }) + } catch (error) { + drop(identity, `sendChatMessage() was dropped: ${errorMessage(error)}`) } - if (!admitChatMessage(identity, source, text)) return - emitter.emit('sendChatMessage', { - message: text, - source, - ...(context !== undefined - ? { context: narrowChatContext(identity, context, drop) } - : {}) - }) } } return { @@ -201,37 +192,33 @@ function createRuntime(workspaceId: string) { } } -// The message carries the user's intent, so a bad `context` drops the payload -// and keeps the message rather than losing the whole call. Returns undefined -// for anything that can't ride the envelope: a non-record, something that -// won't serialize (cycles, functions, BigInt), or an oversized blob. -function narrowChatContext( - identity: AppletIdentity, - context: unknown, - drop: (identity: AppletIdentity, message: string) => void -): Record | undefined { - if (!isParamsRecord(context)) { - drop(identity, 'sendChatMessage() ignored its context field: it must be a plain object.') - return undefined - } - let json: string - try { - json = JSON.stringify(context) - } catch { - drop( - identity, - 'sendChatMessage() ignored its context field: it is not JSON-serializable (cycles, functions, or BigInt).' - ) - return undefined - } - if (json.length > MAX_APPLET_CONTEXT_CHARS) { - drop( - identity, - `sendChatMessage() ignored its context field: ${json.length} characters serialized (max ${MAX_APPLET_CONTEXT_CHARS}). Send an id the agent can look up instead of the whole payload.` - ) - return undefined +// Copy the caller's fields now so later applet mutations cannot change a send +// or staged attachment. File contents are immutable and can be retained as-is. +function snapshotAttachmentInput(input: unknown, source: string): AppletChatAttachment { + if (isParamsRecord(input)) { + if (input.type === 'text') { + const snapshot = snapshotTextAttachment(input) + if (snapshot) return { type: 'text', ...snapshot, source } + } else if (input.type === 'file') { + if ( + input.file instanceof File && + input.path === undefined && + input.file.size <= MAX_UPLOAD_BYTES + ) { + return { type: 'file', file: input.file, source } + } + if (isWorkspaceAttachmentPath(input.path) && input.file === undefined) { + return { type: 'file', path: input.path, source } + } + } } - return context + throw new Error( + 'provide labelled text (label max 120 characters, text max 5000), or exactly one File (max 32 MB) or workspace-relative path. Hidden paths are not allowed.' + ) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) } type AppletRuntime = ReturnType diff --git a/client/features/chat/applet-chat-intents.test.ts b/client/features/chat/applet-chat-intents.test.ts index a61fcca8..e9d498c8 100644 --- a/client/features/chat/applet-chat-intents.test.ts +++ b/client/features/chat/applet-chat-intents.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test, mock, spyOn } from 'bun:test' +import { QueryClient } from '@tanstack/react-query' +import { appUiKeys } from '@/client/api/app-ui-keys' +import { toast } from '@/client/components/ui/toast' +import * as appletLog from '@/client/features/applets/applet-log' +import type { AppletChatMessage } from '@/client/features/applets/applet-runtime' +import { type ChatSendOptions, ownsComposerAttachments, attachmentsForSend } from './chat-send' +import { liveStore } from './chat-store' +import { stageTextAttachment } from './composer/attachments/draft-attachments' +import type { SelectedSessionState, UploadInfo } from '@/lib/types' +import { drainChatDirectives, pushChatDirective } from '@/client/features/workspace/moi-context' import { canSubmitComposerAction } from '@/client/components/shared/Composer' -import { appletSendBlockedReason } from '@/client/features/chat/applet-chat-intents' +import { + appletSendBlockedReason, + createAppletMessageHandler +} from '@/client/features/chat/applet-chat-intents' import type { AgentAvailability } from '@/client/lib/agent-availability' // An applet message must not start a run the composer's own send button would @@ -52,3 +65,258 @@ describe('appletSendBlockedReason', () => { } }) }) + +// Exercise the actual async handler with deferred uploads and the real selection +// cache, including changes that happen without an intervening React render. +describe('immediate applet sends', () => { + const workspaceId = 'immediate-send-test' + const originalFetch = globalThis.fetch + let handler: ReturnType + let queryClient: QueryClient + let availability: AgentAvailability + let send: ReturnType void>> + let reveal: ReturnType void>> + let notices: ReturnType> + let close: ReturnType> + let log: ReturnType> + + function select(sessionId: string | null) { + queryClient.setQueryData(appUiKeys.selectedSession(workspaceId), { sessionId }) + } + function deferredUpload() { + const deferred = Promise.withResolvers() + globalThis.fetch = mock(() => deferred.promise) as unknown as typeof fetch + return deferred + } + const upload: UploadInfo = { + id: 'upload-1', + kind: 'file', + filename: 'report.pdf', + mediaType: 'application/pdf', + size: 20 + } + const event: AppletChatMessage = { + message: 'Review', + source: 'view:orders', + attachments: [{ type: 'file', path: 'report.pdf', source: 'view:orders' }] + } + + beforeEach(() => { + queryClient = new QueryClient() + select('session-1') + availability = { status: 'available' } + send = mock(() => {}) + reveal = mock(() => {}) + notices = spyOn(toast, 'add').mockImplementation(() => crypto.randomUUID()) + close = spyOn(toast, 'close').mockImplementation(() => {}) + log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) + handler = createAppletMessageHandler(workspaceId, queryClient, () => ({ + sessionId: + queryClient.getQueryData(appUiKeys.selectedSession(workspaceId)) + ?.sessionId ?? null, + send, + revealChat: reveal, + agentAvailability: availability + })) + }) + afterEach(() => { + handler.dispose() + queryClient.clear() + globalThis.fetch = originalFetch + notices.mockRestore() + close.mockRestore() + log.mockRestore() + liveStore.setState({ attachments: {} }) + }) + + test('message-only sends preserve attribution and do not show a loading toast', async () => { + await handler.handle({ ...event, attachments: [] }) + expect(reveal).toHaveBeenCalledTimes(1) + expect(send).toHaveBeenCalledWith('Review', { + applet: { source: 'view:orders' }, + preparedAttachments: { attachments: [], parts: [] } + }) + expect(notices).not.toHaveBeenCalled() + }) + + test('prepares all three inputs in order without deduplication or draft mutations', async () => { + const delayed = Promise.withResolvers() + const requests: [string, RequestInit | undefined][] = [] + globalThis.fetch = mock((url: string, init?: RequestInit) => { + requests.push([url, init]) + return url.endsWith('/from-path') ? Promise.resolve(Response.json(upload)) : delayed.promise + }) as unknown as typeof fetch + stageTextAttachment( + { workspaceId, sessionId: 'session-1' }, + { source: 'view:draft', label: 'Keep', text: 'draft' } + ) + const draft = liveStore.getState().attachments + const text = { type: 'text' as const, source: 'view:orders', label: 'Order', text: 'Details' } + const pending = handler.handle({ + ...event, + attachments: [ + text, + { + type: 'file', + file: new File(['image'], 'image.png', { type: 'image/png' }), + source: event.source + }, + ...event.attachments, + text + ] + }) + expect(send).not.toHaveBeenCalled() + expect(reveal).toHaveBeenCalledTimes(1) + expect(notices.mock.calls[0][0]).toMatchObject({ type: 'loading', timeout: 0 }) + expect(liveStore.getState().attachments).toBe(draft) + delayed.resolve( + Response.json([ + { ...upload, id: 'image-1', kind: 'image', filename: 'image.png', mediaType: 'image/png' } + ]) + ) + await pending + expect(liveStore.getState().attachments).toBe(draft) + const options = send.mock.calls[0][1]! + expect(ownsComposerAttachments(options)).toBe(false) + expect(attachmentsForSend(workspaceId, 'session-1', options)).toEqual([]) + expect(options.preparedAttachments).toEqual({ + attachments: [ + text, + { type: 'upload', uploadId: 'image-1' }, + { type: 'upload', uploadId: 'upload-1' }, + text + ], + parts: [ + { ...text, type: 'text-attachment' }, + { + type: 'file-attachment', + filename: 'image.png', + mediaType: 'image/png', + url: `/api/workspaces/${workspaceId}/uploads/image-1` + }, + { type: 'file-attachment', filename: 'report.pdf', mediaType: 'application/pdf', url: '' }, + { ...text, type: 'text-attachment' } + ] + }) + expect(requests[0][1]?.body).toBeInstanceOf(FormData) + expect(requests[1][1]?.body).toBe(JSON.stringify({ path: 'report.pdf' })) + expect(close).toHaveBeenCalledTimes(1) + }) + + test('one failed file prevents the whole send and reports the failure', async () => { + globalThis.fetch = mock((url: string, init?: RequestInit) => + Promise.resolve( + String(init?.body).includes('missing') + ? new Response('File not found', { status: 404 }) + : Response.json(upload) + ) + ) as unknown as typeof fetch + await handler.handle({ + ...event, + attachments: [ + ...event.attachments, + { type: 'file', path: 'missing.pdf', source: event.source } + ] + }) + expect(send).not.toHaveBeenCalled() + expect(notices.mock.calls.at(-1)?.[0]).toMatchObject({ + type: 'error', + description: 'File not found' + }) + expect(log.mock.calls[0][1].message).toContain('view:orders: File not found') + expect(close).toHaveBeenCalledTimes(1) + }) + + for (const transition of ['switch', 'switch-back', 'rename', 'new-chat-id', 'unmount'] as const) { + test(`cancels permanently on ${transition}`, async () => { + if (transition === 'new-chat-id') select(null) + const upload = deferredUpload() + const pending = handler.handle(event) + if (transition === 'unmount') handler.dispose() + else { + select('session-2') + if (transition === 'switch-back') select('session-1') + } + expect(notices.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Message canceled' }) + expect(close).toHaveBeenCalledTimes(1) + upload.resolve(Response.json({ id: 'ignored' })) + await pending + expect(send).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledTimes(1) + }) + } + + test('ignores an upload rejection after cancellation', async () => { + const upload = deferredUpload() + const pending = handler.handle(event) + select(null) + upload.reject(new Error('late failure')) + await pending + expect(send).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() + expect(notices.mock.calls.filter(([notice]) => notice.type === 'error')).toHaveLength(0) + }) + + test('rechecks availability after uploads', async () => { + const deferred = deferredUpload() + const pending = handler.handle(event) + availability = { status: 'disconnected' } + deferred.resolve(Response.json(upload)) + await pending + expect(send).not.toHaveBeenCalled() + expect(notices.mock.calls.at(-1)?.[0]).toMatchObject({ + type: 'error', + description: 'this workspace is disconnected from moi' + }) + }) + + test('unavailable agents do not start uploads', async () => { + const fetch = mock(() => Promise.reject(new Error('unexpected request'))) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + availability = { status: 'checking' } + await handler.handle(event) + expect(fetch).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) + + test('a stale rendered send callback cannot target a previously selected chat', async () => { + handler.dispose() + handler = createAppletMessageHandler(workspaceId, queryClient, () => ({ + sessionId: 'old-session', + send, + revealChat: reveal, + agentAvailability: availability + })) + const fetch = mock(() => Promise.reject(new Error('unexpected upload'))) + globalThis.fetch = fetch as unknown as typeof globalThis.fetch + await handler.handle(event) + expect(fetch).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + expect(notices.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Message canceled' }) + }) + + test('one-shot directives wait for an actual send after cancellation', async () => { + pushChatDirective(workspaceId, 'Preserve this instruction') + send.mockImplementation(() => { + expect(drainChatDirectives(workspaceId)).toEqual(['Preserve this instruction']) + }) + const deferred = deferredUpload() + const pending = handler.handle(event) + select('session-2') + deferred.resolve(Response.json(upload)) + await pending + expect(send).not.toHaveBeenCalled() + await handler.handle({ ...event, attachments: [] }) + expect(send).toHaveBeenCalledTimes(1) + expect(drainChatDirectives(workspaceId)).toEqual([]) + }) + + test('the send itself may assign a new session ID without a cancellation notice', async () => { + select(null) + send.mockImplementation(() => select('minted-session')) + await handler.handle({ ...event, attachments: [] }) + expect(send).toHaveBeenCalledTimes(1) + expect(notices).not.toHaveBeenCalled() + }) +}) diff --git a/client/features/chat/applet-chat-intents.ts b/client/features/chat/applet-chat-intents.ts index 4d6d8a89..8b497ac1 100644 --- a/client/features/chat/applet-chat-intents.ts +++ b/client/features/chat/applet-chat-intents.ts @@ -1,15 +1,30 @@ // Connect validated applet intents to the active chat: stage attachments in the // draft or send a message immediately, then reveal and focus chat. // The runtime owns argument validation, source attribution, and send rate limits; -// these hooks own draft staging and agent availability checks for immediate sends. +// these handlers own draft staging, preparation, and cancellation of immediate sends. import { stageChatAttachment } from './composer/attachments/draft-attachments' import { reportAppletError } from '@/client/features/applets/applet-log' -import { type AppletChatMessage, useAppletEvent } from '@/client/features/applets/applet-runtime' -import type { ChatSendOptions } from '@/client/features/chat/chat-send' +import { + type AppletChatMessage, + appletRuntime, + useAppletEvent +} from '@/client/features/applets/applet-runtime' +import { + prepareChatAttachments, + type ChatSendOptions, + type PreparedChatAttachments +} from './chat-send' +import { useLayoutEffect } from 'react' +import { useQueryClient, type QueryClient } from '@tanstack/react-query' +import { appUiKeys } from '@/client/api/app-ui-keys' +import { toast } from '@/client/components/ui/toast' +import { useLatestRef } from '@/client/lib/use-latest-ref' +import type { SelectedSessionState } from '@/lib/types' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import type { AgentAvailability } from '@/client/lib/agent-availability' type UseAppletChatMessageOptions = { + sessionId: string | null send: (draft: string, options?: ChatSendOptions) => void // Bring the chat on screen before the run starts. On a view tab in // full-screen mode the chat is a popover that is closed by default, so @@ -34,30 +49,118 @@ export function appletSendBlockedReason(availability: AgentAvailability): string return "this workspace's agent is unavailable" } -export function useAppletChatMessage({ - send, - revealChat, - agentAvailability -}: UseAppletChatMessageOptions): void { - const workspaceId = useWorkspaceId() +// One handler lifetime belongs to one workspace screen. Listen to selection +// changes in the query cache itself: switching away and back can happen before +// React renders, and must still cancel the pending send. +export function createAppletMessageHandler( + workspaceId: string, + queryClient: QueryClient, + getOptions: () => UseAppletChatMessageOptions +) { + const pending = new Set<() => void>() + let disposed = false + const selectedSession = () => + queryClient.getQueryData(appUiKeys.selectedSession(workspaceId)) + ?.sessionId ?? null - useAppletEvent(workspaceId, 'sendChatMessage', (event: AppletChatMessage) => { - const blocked = appletSendBlockedReason(agentAvailability) - if (blocked) { - // Journaled rather than dropped quietly: from the applet's side the - // button did nothing, and this is the only place that says why. + async function handle(event: AppletChatMessage): Promise { + if (disposed) return + const reportFailure = (message: string) => { + toast.add({ title: 'Couldn’t send message', description: message, type: 'error' }) reportAppletError(workspaceId, { source: 'runtime', - message: `sendChatMessage() for "${event.message}" was dropped: ${blocked}.` + message: `sendChatMessage() from ${event.source}: ${message}` }) + } + const blocked = appletSendBlockedReason(getOptions().agentAvailability) + if (blocked) { + reportFailure(blocked) return } - // `message` is the visible chat text; everything else in the event IS the - // envelope's applet attribution. - const { message, ...applet } = event - revealChat() - send(message, { applet }) - }) + + const sessionId = selectedSession() + let active = true + let loadingToast: string | undefined + const finish = () => { + active = false + unsubscribe() + pending.delete(cancel) + if (loadingToast) toast.close(loadingToast) + } + const cancel = () => { + if (!active) return + finish() + toast.add({ + title: 'Message canceled', + description: 'The active chat changed before attachments were ready.', + type: 'info' + }) + } + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + if (selectedSession() !== sessionId) cancel() + }) + pending.add(cancel) + // A selection change can reach the cache before React has replaced the + // send callback. Never invoke a callback that still targets the old chat. + if (getOptions().sessionId !== sessionId) { + cancel() + return + } + getOptions().revealChat() + + let preparedAttachments: PreparedChatAttachments + try { + if (event.attachments.some(attachment => attachment.type === 'file')) { + loadingToast = toast.add({ title: 'Preparing attachments…', type: 'loading', timeout: 0 }) + } + preparedAttachments = await prepareChatAttachments(workspaceId, event.attachments) + if (!active) return + const options = getOptions() + const blocked = appletSendBlockedReason(options.agentAvailability) + if (blocked) throw new Error(blocked) + if (options.sessionId !== sessionId) { + cancel() + return + } + } catch (error) { + if (active) { + finish() + reportFailure(error instanceof Error ? error.message : 'Attachment preparation failed') + } + return + } + // Stop watching before sending: a new chat gets its first session ID here. + finish() + try { + getOptions().send(event.message, { applet: { source: event.source }, preparedAttachments }) + } catch (error) { + reportFailure(error instanceof Error ? error.message : 'Message send failed') + } + } + + return { + handle, + dispose() { + disposed = true + for (const cancel of pending) cancel() + } + } +} + +export function useAppletChatMessage(options: UseAppletChatMessageOptions): void { + const workspaceId = useWorkspaceId() + const queryClient = useQueryClient() + const latest = useLatestRef(options) + useLayoutEffect(() => { + const handler = createAppletMessageHandler(workspaceId, queryClient, () => latest.current) + const unsubscribe = appletRuntime(workspaceId).on('sendChatMessage', event => { + void handler.handle(event) + }) + return () => { + unsubscribe() + handler.dispose() + } + }, [workspaceId, queryClient, latest]) } export function useAppletChatAttachment(sessionId: string | null, revealChat: () => void): void { diff --git a/client/features/chat/chat-send.ts b/client/features/chat/chat-send.ts index 1dee888d..ae81c720 100644 --- a/client/features/chat/chat-send.ts +++ b/client/features/chat/chat-send.ts @@ -1,4 +1,6 @@ import type { MessageAttachment } from '@/lib/types' +import type { AppletChatAttachment } from '@/client/features/applets/applet-runtime' +import { uploadChatFile } from './composer/attachments/uploads' import { textAttachmentParts } from '@/lib/moi-attachments' import type { QueryClient } from '@tanstack/react-query' @@ -12,16 +14,50 @@ import { formatChatTitle } from '@/lib/chat-title' import { applyEvent, emptyViewState } from '@/lib/format' import type { Part, SessionInfo, ViewState, WorkspaceAgent } from '@/lib/types' -// What a caller may attach to one message beyond its text. All of it is -// envelope material — the agent sees it, the chat bubble does not. -export type ChatSendOptions = MoiUserMessageOptions +// Explicit attachments belong to this send, independently of the user's draft. +export type PreparedChatAttachments = { + attachments: MessageAttachment[] + parts: Part[] +} + +export type ChatSendOptions = MoiUserMessageOptions & { + preparedAttachments?: PreparedChatAttachments +} + +export async function prepareChatAttachments( + workspaceId: string, + inputs: readonly AppletChatAttachment[] +): Promise { + const prepared = await Promise.all( + inputs.map(async input => { + if (input.type === 'text') { + const { source, label, text } = input + const attachment: MessageAttachment = { type: 'text', source, label, text } + return { attachment, part: textAttachmentParts([attachment])[0] } + } + const upload = await uploadChatFile(workspaceId, input.file ?? input.path) + const attachment: MessageAttachment = { type: 'upload', uploadId: upload.id } + const part: Part = { + type: 'file-attachment', + mediaType: upload.mediaType, + filename: upload.filename, + url: upload.kind === 'image' ? `/api/workspaces/${workspaceId}/uploads/${upload.id}` : '' + } + return { attachment, part } + }) + ) + return { + attachments: prepared.map(item => item.attachment), + parts: prepared.map(item => item.part) + } +} // Whether this send owns what the user has staged in the composer. Only a send // FROM the composer does. An applet's message is not the message the user is // building, so it must neither carry files they staged for their own message // nor clear ones still uploading out from under them. export function ownsComposerAttachments(options?: ChatSendOptions): boolean { - return !options?.applet + return !options?.applet && !options?.preparedAttachments } // The fully-uploaded attachments this send should carry — none for a send that diff --git a/client/features/chat/composer/attachments/draft-attachments.ts b/client/features/chat/composer/attachments/draft-attachments.ts index affa229c..dd8dde41 100644 --- a/client/features/chat/composer/attachments/draft-attachments.ts +++ b/client/features/chat/composer/attachments/draft-attachments.ts @@ -3,7 +3,7 @@ import type { WorkspaceTabId } from '@/lib/types' import { attachmentKey, liveStore } from '../../chat-store' import type { DrawingPurpose } from './types' -import { uploadFiles, uploadWorkspaceFile } from './uploads' +import { uploadFiles, uploadChatFile } from './uploads' import type { AppletChatAttachment } from '@/client/features/applets/applet-runtime' import { reportAppletError } from '@/client/features/applets/applet-log' @@ -35,10 +35,7 @@ async function stageFile( } ]) try { - const upload = - typeof input === 'string' - ? await uploadWorkspaceFile(workspaceId, input) - : (await uploadFiles(workspaceId, [input]))[0] + const upload = await uploadChatFile(workspaceId, input) liveStore.getState().updateAttachment(workspaceId, localId, { status: 'ready', upload, diff --git a/client/features/chat/composer/attachments/uploads.ts b/client/features/chat/composer/attachments/uploads.ts index 32d7b4bd..d64034d2 100644 --- a/client/features/chat/composer/attachments/uploads.ts +++ b/client/features/chat/composer/attachments/uploads.ts @@ -26,3 +26,16 @@ export async function uploadWorkspaceFile(workspaceId: string, path: string): Pr if (!res.ok) throw new Error(await res.text().catch(() => `Upload failed (${res.status})`)) return res.json() } + +// Shared preparation for draft attachments and immediate applet sends. +export async function uploadChatFile( + workspaceId: string, + input: File | string +): Promise { + const upload = + typeof input === 'string' + ? await uploadWorkspaceFile(workspaceId, input) + : (await uploadFiles(workspaceId, [input]))[0] + if (!upload) throw new Error('Upload returned no file') + return upload +} diff --git a/client/features/chat/useChat.ts b/client/features/chat/useChat.ts index 97cf7739..e045724f 100644 --- a/client/features/chat/useChat.ts +++ b/client/features/chat/useChat.ts @@ -106,12 +106,12 @@ export function useChat(address: WorkspaceTabAddress) { const text = draft.trim() // Attachments stay with the selected session. Only fully-uploaded ones are // sent; the composer disables send while any are still uploading, so in - // practice they're all ready here. An applet send gets none — the staged - // files are the user's, not the widget's. + // practice they're all ready here. Applet sends supply their own prepared + // attachments and leave the user's staged files alone. const ready = attachmentsForSend(workspaceId, selectedSessionId, options) // No `processing` guard: sending while a turn is in flight QUEUES the // message into the same live server session (streaming-input mode). - if (!text && ready.length === 0) return + if (!text && ready.length === 0 && !options?.preparedAttachments?.attachments.length) return let sid = selectedSessionId let isNew = false @@ -137,7 +137,9 @@ export function useChat(address: WorkspaceTabAddress) { // upserts in place rather than duplicating. Image attachments render from // their local object URL until the server's broadcast (with a data URL) // upserts in place. - const parts: Part[] = attachmentPartsForOptimisticTurn(ready) + const parts: Part[] = options?.preparedAttachments + ? [...options.preparedAttachments.parts] + : attachmentPartsForOptimisticTurn(ready) if (text) parts.push({ type: 'text', text }) const optimisticId = startOptimisticTurn({ queryClient: qc, @@ -159,7 +161,8 @@ export function useChat(address: WorkspaceTabAddress) { pickedEffort, pickedFastMode ) - const attachments = messageAttachmentsForSend(ready) + const attachments = + options?.preparedAttachments?.attachments ?? messageAttachmentsForSend(ready) sendMessage({ type: 'chat', workspaceId, @@ -180,7 +183,7 @@ export function useChat(address: WorkspaceTabAddress) { } // Drop the session's attachments now that they've been sent (revokes the // preview object URLs). Keyed by the pre-mint id, matching where they were - // stored by the composer. Skipped for an applet send, which carried none: + // stored by the composer. Skipped for an applet send: // clearing here would discard the user's staged (and in-flight) files. if (ownsComposerAttachments(options)) { liveStore.getState().clearAttachments(workspaceId, selectedSessionId) diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 607e9bb5..7d755964 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -456,7 +456,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // Chat messages fired from applet UI. `openChat` is the reveal: on a view tab // in full-screen mode the chat is a closed popover, and a run the user can't // see is worse than a panel that opens itself. - useAppletChatMessage({ send, revealChat: openChat, agentAvailability }) + useAppletChatMessage({ sessionId, send, revealChat: openChat, agentAvailability }) useAppletChatAttachment(sessionId, openChat) const createItems: CreateWorkspaceTabItem[] = [ diff --git a/lib/moi-context.ts b/lib/moi-context.ts index 23ee8b7e..f795a235 100644 --- a/lib/moi-context.ts +++ b/lib/moi-context.ts @@ -39,9 +39,6 @@ const SYSTEM_REMINDER_CLOSE = '' // claim to be another one. export type MoiAppletMessage = { source: string - // The structured payload the applet attached to the call. JSON-plain, and - // dropped entirely when it doesn't survive serialization. - context?: Record } // The structured form built at send time — by the client for chat sends, by @@ -68,14 +65,10 @@ export type MoiContext = { directives?: string[] } -// Cap on applet-authored JSON rendered into the envelope. Shared with the -// client applet runtime, which drops an oversized `context` at the trust -// boundary rather than letting it ride the wire — the renderer's truncation is -// the backstop for anything that still gets through (e.g. a server-built -// context). -export const MAX_APPLET_CONTEXT_CHARS = 2000 +// Cap on ambient view params rendered into the workspace envelope. +const MAX_TAB_PARAMS_CHARS = 2000 -// Applet-authored strings (view titles, applet names, attached context) get +// Applet-authored strings (view titles, applet names, view params) get // interpolated into the envelope, and applet code is agent-authored — a // crafted value containing `` would otherwise close the envelope // early and forge sections the host never wrote. Escaping `<` defuses every @@ -88,7 +81,7 @@ function escapeTags(text: string): string { // Render an applet-authored record for the envelope, or null when there's // nothing worth printing. Non-serializable values (cycles, BigInt) drop rather // than throw mid-send. -function renderAppletJson(value: Record): string | null { +function renderTabParams(value: Record): string | null { let json: string try { json = JSON.stringify(value) @@ -97,8 +90,8 @@ function renderAppletJson(value: Record): string | null { } if (!json || json === '{}') return null const capped = - json.length > MAX_APPLET_CONTEXT_CHARS - ? `${json.slice(0, MAX_APPLET_CONTEXT_CHARS)}… (truncated)` + json.length > MAX_TAB_PARAMS_CHARS + ? `${json.slice(0, MAX_TAB_PARAMS_CHARS)}… (truncated)` : json return escapeTags(capped) } @@ -155,16 +148,13 @@ export function renderMoiContextBody(ctx: MoiContext): string { 'Read the **`moi-workspace` skill** before responding — even to a simple question — unless you already read it in this chat.' ].join('\n') const tabLines = [describeTab(ctx.activeTab, ctx.tabTitle)] - const tabParams = ctx.tabParams ? renderAppletJson(ctx.tabParams) : null + const tabParams = ctx.tabParams ? renderTabParams(ctx.tabParams) : null if (tabParams) tabLines.push(`Params it is rendering with right now: ${tabParams}`) const sections = [`# Active tab\n${tabLines.join('\n')}`] if (ctx.applet) { - const appletLines = [ - `The message above was not typed by the user — the ${describeAppletSource(ctx.applet.source)} sent it when the user acted in its UI.` - ] - const context = ctx.applet.context ? renderAppletJson(ctx.applet.context) : null - if (context) appletLines.push(`It attached this context: ${context}`) - sections.push(`# Applet message\n${appletLines.join('\n')}`) + sections.push( + `# Applet message\nThe message above was not typed by the user — the ${describeAppletSource(ctx.applet.source)} sent it when the user acted in its UI.` + ) } if (ctx.directives?.length) { sections.push(`# This message only\n${ctx.directives.join('\n')}`) @@ -202,12 +192,7 @@ export function isMoiContext(value: unknown): value is MoiContext { function isMoiAppletMessage(value: unknown): value is MoiAppletMessage { if (!isParamsRecord(value)) return false - const v = value as { source?: unknown; context?: unknown } - return ( - typeof v.source === 'string' && - v.source.length > 0 && - (v.context === undefined || isParamsRecord(v.context)) - ) + return typeof value.source === 'string' && value.source.length > 0 } // Claude Code: the envelope rides as its OWN text block wrapped in diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index 08a7269d..589a328a 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -125,7 +125,7 @@ export function rpc(module, name) { // per-segment URL-encoded so spaces / unicode in filenames survive. A leading // slash is stripped so both `clips/a.mp4` and `/clips/a.mp4` work. // -// `focusTab(tab, params?)` and `sendChatMessage({ message, context? })` forward to +// `focusTab(tab, params?)` and `sendChatMessage({ message, attachments? })` forward to // this bundle's host-attached bridge — client-local replace-navigation to a // workspace tab (params delivered to the target view via navigation state), // and a chat message sent to the workspace's active chat as if the user had @@ -162,8 +162,9 @@ export function addChatAttachment(input) { bridge?.addChatAttachment(input); } -export function sendChatMessage(input, context) { - bridge?.sendChatMessage(input, context); +// Keep positional calls working for previously built applets. +export function sendChatMessage(input, legacyContext) { + bridge?.sendChatMessage(input, legacyContext); } ` diff --git a/server/moi-scaffold.ts b/server/moi-scaffold.ts index 9bffd870..6a630012 100644 --- a/server/moi-scaffold.ts +++ b/server/moi-scaffold.ts @@ -108,12 +108,10 @@ declare module 'moi' { | { type: 'file'; path: string; file?: never } // Stage an attachment in the current chat draft; does not send a message. export function addChatAttachment(input: ChatAttachmentInput): void - // Send a chat message to the workspace's active chat, as if the user typed - // \`message\`. \`context\` rides along as structured data the agent sees but - // the user does not — JSON-plain values only. Call it from event handlers, - // never during render: each call starts an agent run, and repeats are - // rate-limited. - export function sendChatMessage(input: { message: string; context?: Record }): void + // Prepare optional attachments and send to the active chat without changing + // its draft. Call from event handlers; each call starts an agent run and is + // rate-limited. Preparation failure or navigation cancels the whole send. + export function sendChatMessage(input: { message: string; attachments?: ChatAttachmentInput[] }): void export type WidgetConfig = { rowSpan: 1 | 2 | 3 | 4 colSpan: 1 | 2 | 3 | 4 diff --git a/server/test/__fixtures__/with-sendchatmessage.tsx b/server/test/__fixtures__/with-sendchatmessage.tsx index aa456cec..093cfd00 100644 --- a/server/test/__fixtures__/with-sendchatmessage.tsx +++ b/server/test/__fixtures__/with-sendchatmessage.tsx @@ -2,6 +2,6 @@ import { sendChatMessage } from 'moi' export const config = { title: 'Chase' } export default function WithChat() { return ( - + ) } diff --git a/server/test/build-applet.test.ts b/server/test/build-applet.test.ts index 4267076b..023fa64e 100644 --- a/server/test/build-applet.test.ts +++ b/server/test/build-applet.test.ts @@ -528,7 +528,7 @@ describe('moi fileUrl module', () => { // Two args only: the applet cannot pass a source, because attribution is // stamped host-side (applet-runtime.ts) from the identity the bridge was // attached with. - expect(result.js).toContain('sendChatMessage(input, context)') + expect(result.js).toContain('sendChatMessage(input, legacyContext)') }) test('bundles addChatAttachment through the applet bridge', async () => { diff --git a/server/test/moi-context.test.ts b/server/test/moi-context.test.ts index 54853b3c..f496f92e 100644 --- a/server/test/moi-context.test.ts +++ b/server/test/moi-context.test.ts @@ -97,7 +97,7 @@ describe('moi context envelope', () => { isMoiContext({ activeTab: 'view:crm', tabParams: { deal: 'd-1' }, - applet: { source: 'widget:pipeline', context: { deal: 'd-1' } } + applet: { source: 'widget:pipeline' } }) ).toBe(true) expect(isMoiContext(undefined)).toBe(false) @@ -107,24 +107,20 @@ describe('moi context envelope', () => { expect(isMoiContext({ activeTab: 'agent', tabParams: ['a'] })).toBe(false) expect(isMoiContext({ activeTab: 'agent', applet: { source: '' } })).toBe(false) expect(isMoiContext({ activeTab: 'agent', applet: { context: { a: 1 } } })).toBe(false) - expect( - isMoiContext({ activeTab: 'agent', applet: { source: 'widget:x', context: 'no' } }) - ).toBe(false) }) - test('an applet-sent message names the applet and its file, and carries its context', () => { + test('an applet-sent message names the applet and its file', () => { const rendered = renderMoiContext({ activeTab: 'view:orders', tabTitle: 'Orders', - applet: { source: 'widget:late-orders', context: { order: 'A-1042', carrier: 'dhl' } } + applet: { source: 'widget:late-orders' } }) expect(rendered).toContain( '# Applet message\nThe message above was not typed by the user — the "late-orders" widget (.moi/widgets/late-orders.tsx) sent it when the user acted in its UI.' ) - expect(rendered).toContain('It attached this context: {"order":"A-1042","carrier":"dhl"}') }) - test('an applet message with no context renders without a context line', () => { + test('applet attribution adds no JSON context line', () => { const rendered = renderMoiContext({ activeTab: 'overview', applet: { source: 'view:board' } @@ -158,7 +154,7 @@ describe('moi context envelope', () => { activeTab: 'view:orders', tabTitle: escape, tabParams: { note: escape }, - applet: { source: `widget:${escape}`, context: { note: escape } } + applet: { source: `widget:${escape}` } }) // Exactly one envelope: the open tag at the start, the close tag at the end. expect(rendered.indexOf('')).toBe(rendered.length - ''.length) @@ -168,10 +164,10 @@ describe('moi context envelope', () => { expect(stripMoiContext(appendMoiContext('Fix the header', rendered))).toBe('Fix the header') }) - test('an oversized applet context is truncated, not sent whole', () => { + test('oversized ambient tab params are still truncated', () => { const rendered = renderMoiContext({ activeTab: 'overview', - applet: { source: 'widget:noisy', context: { blob: 'x'.repeat(5000) } } + tabParams: { blob: 'x'.repeat(5000) } }) expect(rendered).toContain('… (truncated)') expect(rendered.length).toBeLessThan(3000) diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md index 6ce301e4..3fa676d9 100644 --- a/workspace/.claude/skills/moi-workspace/references/INTENTS.md +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -113,22 +113,25 @@ removable error chip. File loading blocks sending until it finishes. Errors are Identical text from the same applet with the same label is skipped. Attachments stay with their chat in memory and are lost on page reload. Text attachments have no preview or editing UI. -## `sendChatMessage({ message, context? })` +## `sendChatMessage({ message, attachments? })` Use this for a complete, ready-to-run action when the user is not expected to add or change -anything before it starts. It opens chat and sends immediately to the active chat without -consuming staged draft attachments. Use action message copy that makes the immediate send clear. +anything before it starts. It opens chat, prepares its attachments, and sends automatically. +The user's draft text and staged attachments stay untouched. Use action copy that makes the +immediate send clear. ### API ```ts -function sendChatMessage(input: { message: string; context?: Record }): void +function sendChatMessage(input: { + message: string + attachments?: ChatAttachmentInput[] +}): void ``` -- `message`: required user-visible message. Whitespace is trimmed; empty messages are ignored. - Maximum length: 1,000 characters. -- `context`: optional JSON object with data or task instructions for the agent, hidden from the - visible message text. Maximum serialized length: 2,000 characters. +- `message`: required user-visible message, trimmed, non-empty, and at most 1,000 characters. +- `attachments`: optional list of `ChatAttachmentInput` values, defined above under + `addChatAttachment`. The same limits and snapshot behavior apply. Supplied order is preserved. ### Example @@ -136,16 +139,22 @@ function sendChatMessage(input: { message: string; context?: Record sendChatMessage({ - message: 'Chase order o-1024', - context: { order: 'o-1024', carrier: 'dhl' } + message: 'Review this order', + attachments: [ + { type: 'text', label: 'Order #1042', text: 'Status: delayed' }, + { type: 'file', path: 'reports/order-1042.pdf' } + ] })}> - Chase order + Review order ``` ### Limits -An oversized message is rejected. Invalid or oversized context is omitted while the message -still sends. Identical messages from the same applet within two seconds are dropped; the -workspace allows up to 10 applet sends per minute. Calls are also dropped when the agent is -unavailable. +Invalid arguments or a failed attachment reject the entire send. File preparation shows a +loading notice; failures show an error and are recorded in `moi debug logs`. Switching chats +or workspaces while files are preparing cancels the send, even if the user switches back. + +Identical messages from the same applet within two seconds are dropped; the workspace allows +up to 10 applet sends per minute. Calls are dropped when the agent is unavailable, including +if it becomes unavailable during preparation. From 0bf42dcd18a3b395663157004f1a8f58174f885b Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 17:21:02 +0200 Subject: [PATCH 13/24] Fix attachment formatting --- client/features/dev/chat-states-fixtures.ts | 7 ++++++- server/harness/claude-code/adapter.ts | 5 ++++- server/test/attachments.test.ts | 4 +++- server/uploads.ts | Bin 11134 -> 11153 bytes 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/client/features/dev/chat-states-fixtures.ts b/client/features/dev/chat-states-fixtures.ts index f3d2b2a9..563c71ae 100644 --- a/client/features/dev/chat-states-fixtures.ts +++ b/client/features/dev/chat-states-fixtures.ts @@ -531,7 +531,12 @@ const attachmentsTurn = turn('user', 23, 26, [ label: 'Selected board items', text: 'Selected items: item-1, item-2' }, - { type: 'file-attachment', mediaType: 'image/png', url: CHECKERBOARD_PNG, filename: 'pattern.png' }, + { + type: 'file-attachment', + mediaType: 'image/png', + url: CHECKERBOARD_PNG, + filename: 'pattern.png' + }, { type: 'file-attachment', mediaType: 'application/pdf', url: '', filename: 'report.pdf' }, text( 'back to work — mole says the report draft is done. recreate this dither pattern in the scratchpad later, and let him know we got it' diff --git a/server/harness/claude-code/adapter.ts b/server/harness/claude-code/adapter.ts index 281f4c6f..ff172c63 100644 --- a/server/harness/claude-code/adapter.ts +++ b/server/harness/claude-code/adapter.ts @@ -567,7 +567,10 @@ export class ClaudeAdapter { } // An attachment-only message carries a synthesized placeholder // prompt; the bubble should show just the attachments. - if (isAttachmentOnlyPlaceholder(text) && parts.some(p => p.type === 'file-attachment')) { + if ( + isAttachmentOnlyPlaceholder(text) && + parts.some(p => p.type === 'file-attachment') + ) { text = '' } } diff --git a/server/test/attachments.test.ts b/server/test/attachments.test.ts index e92b4749..280482d7 100644 --- a/server/test/attachments.test.ts +++ b/server/test/attachments.test.ts @@ -36,7 +36,9 @@ async function addImage(workspaceId: string, name = 'shot.png') { } function fileParts(parts: Part[]) { - return parts.filter((p): p is Extract => p.type === 'file-attachment') + return parts.filter( + (p): p is Extract => p.type === 'file-attachment' + ) } function textPart(parts: Part[]) { return parts.find((p): p is Extract => p.type === 'text') diff --git a/server/uploads.ts b/server/uploads.ts index bec96bae6634e029f023bfbee6e4172ea1e4b76e..26ab39fe9f1bcc3dbc9789c570c155af7bee8ef0 100644 GIT binary patch delta 85 zcmewtHZgpIHZN0*!Db!acd{~E3P4a&S&(X_pq`eQld79oQj(aQk(-)VqCWYwg3@Fk aZ3zXiB9LreVlGf&sUD0Cl&IyZ Date: Wed, 16 Sep 2026 18:10:05 +0200 Subject: [PATCH 14/24] Remove failed attachments and show error toasts --- .../composer/attachments/AttachmentChip.tsx | 10 +-- .../attachments/FileAttachmentChip.tsx | 2 +- .../attachments/draft-attachments.test.ts | 75 +++++++++++++------ .../composer/attachments/draft-attachments.ts | 16 ++-- .../chat/composer/attachments/types.ts | 5 +- .../moi-workspace/references/INTENTS.md | 4 +- 6 files changed, 65 insertions(+), 47 deletions(-) diff --git a/client/features/chat/composer/attachments/AttachmentChip.tsx b/client/features/chat/composer/attachments/AttachmentChip.tsx index 25f30e77..b787c858 100644 --- a/client/features/chat/composer/attachments/AttachmentChip.tsx +++ b/client/features/chat/composer/attachments/AttachmentChip.tsx @@ -8,7 +8,7 @@ type AttachmentChipProps = ComponentPropsWithRef<'div'> & { icon: Icon onRemove?: () => void previewUrl?: string - status?: 'draft' | 'uploading' | 'ready' | 'error' + status?: 'draft' | 'uploading' | 'ready' } // Two layouts share one frame: a labelled chip or an image thumbnail. @@ -29,7 +29,6 @@ export function AttachmentChip({ className={cn( 'group relative flex max-w-full min-w-0 cursor-default items-center rounded-md bg-background text-sm whitespace-nowrap text-foreground ring-1 ring-border outline-none focus-visible:ring-3 focus-visible:ring-ring/50', previewUrl ? 'size-14 overflow-hidden' : 'h-7 pr-2 pl-0.5', - status === 'error' && 'ring-destructive/50', className )} > @@ -111,12 +110,5 @@ function ChipUploadStatus({ status }: ChipUploadStatusProps) { ) } - if (status === 'error') { - return ( -
- Failed -
- ) - } return null } diff --git a/client/features/chat/composer/attachments/FileAttachmentChip.tsx b/client/features/chat/composer/attachments/FileAttachmentChip.tsx index 395705fb..47825da5 100644 --- a/client/features/chat/composer/attachments/FileAttachmentChip.tsx +++ b/client/features/chat/composer/attachments/FileAttachmentChip.tsx @@ -15,7 +15,7 @@ export function FileAttachmentChip({ attachment, onRemove }: FileAttachmentChipP onRemove={onRemove} previewUrl={attachment.previewUrl} status={attachment.status} - title={attachment.error ?? attachment.name} + title={attachment.name} className="max-w-52" /> ) diff --git a/client/features/chat/composer/attachments/draft-attachments.test.ts b/client/features/chat/composer/attachments/draft-attachments.test.ts index 8ad3a67a..f9adc74d 100644 --- a/client/features/chat/composer/attachments/draft-attachments.test.ts +++ b/client/features/chat/composer/attachments/draft-attachments.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' +import { toast } from '@/client/components/ui/toast' import { stageDrawing, @@ -13,6 +14,11 @@ import { attachmentKey, liveStore } from '../../chat-store' const workspaceId = 'workspace-1' const sessionId = 'session-1' const originalFetch = globalThis.fetch +let notices: ReturnType> + +beforeEach(() => { + notices = spyOn(toast, 'add').mockImplementation(() => crypto.randomUUID()) +}) function drawingAttachments() { return (liveStore.getState().attachments[attachmentKey(workspaceId, sessionId)] ?? []).filter( @@ -21,6 +27,7 @@ function drawingAttachments() { } afterEach(() => { + notices.mockRestore() globalThis.fetch = originalFetch liveStore.getState().clearAttachments(workspaceId, sessionId) liveStore.setState({ attachments: {} }) @@ -286,30 +293,50 @@ describe('applet file staging', () => { expect(attachmentsForSend(workspaceId, sessionId)).toEqual([]) }) - test('path images get an upload preview and failures get a removable error chip and log', async () => { - const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) - const requests: string[] = [] - globalThis.fetch = mock((url: string) => { - requests.push(url) - return Promise.resolve( - url.endsWith('/applet-log') - ? new Response(null, { status: 204 }) - : new Response('Missing file', { status: 400 }) + test.each(['browser', 'path'] as const)( + '%s failures remove only the failed upload and show a toast', + async source => { + const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) + const revoke = spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const response = Promise.withResolvers() + globalThis.fetch = mock(() => response.promise) as unknown as typeof fetch + stageTextAttachment( + { workspaceId, sessionId }, + { label: 'Keep', text: 'Keep this attachment', source: 'view:files' } ) - }) as unknown as typeof fetch - await stageChatAttachment( - { workspaceId, sessionId }, - { type: 'file', path: 'missing.pdf', source: 'view:files' } - ) - const failed = drawingAttachments()[0] - expect(failed).toMatchObject({ status: 'error', error: 'Missing file' }) - expect(log).toHaveBeenCalledWith(workspaceId, { - source: 'runtime', - message: 'addChatAttachment() from view:files: Missing file' - }) - log.mockRestore() - liveStore.getState().removeAttachment(workspaceId, failed.localId) - expect(drawingAttachments()).toEqual([]) + const kept = attachmentsForSend(workspaceId, sessionId) + const pending = stageChatAttachment( + { workspaceId, sessionId }, + source === 'browser' + ? { + type: 'file', + file: new File(['image'], 'image.png', { type: 'image/png' }), + source: 'view:files' + } + : { type: 'file', path: 'missing.pdf', source: 'view:files' } + ) + const failed = drawingAttachments()[0] + expect(failed.status).toBe('uploading') + liveStore.getState().renameSession(workspaceId, sessionId, 'real') + response.resolve(new Response('Upload failed', { status: 400 })) + await pending + expect(liveStore.getState().attachments[attachmentKey(workspaceId, 'real')]).toEqual(kept) + expect(notices).toHaveBeenCalledWith({ + title: `Couldn’t add ${failed.name}`, + description: 'Upload failed', + type: 'error' + }) + if (failed.previewUrl) expect(revoke).toHaveBeenCalledWith(failed.previewUrl) + expect(log).toHaveBeenCalledWith(workspaceId, { + source: 'runtime', + message: 'addChatAttachment() from view:files: Upload failed' + }) + log.mockRestore() + revoke.mockRestore() + } + ) + + test('path images get an upload preview', async () => { globalThis.fetch = mock(() => Promise.resolve( Response.json({ id: 'img', filename: 'image.png', kind: 'image', mediaType: 'image/png' }) diff --git a/client/features/chat/composer/attachments/draft-attachments.ts b/client/features/chat/composer/attachments/draft-attachments.ts index dd8dde41..cfd9943d 100644 --- a/client/features/chat/composer/attachments/draft-attachments.ts +++ b/client/features/chat/composer/attachments/draft-attachments.ts @@ -6,6 +6,7 @@ import type { DrawingPurpose } from './types' import { uploadFiles, uploadChatFile } from './uploads' import type { AppletChatAttachment } from '@/client/features/applets/applet-runtime' import { reportAppletError } from '@/client/features/applets/applet-log' +import { toast } from '@/client/components/ui/toast' type ComposerTarget = { workspaceId: string @@ -23,12 +24,13 @@ async function stageFile( onError?: (message: string) => void ): Promise { const file = typeof input === 'string' ? null : input + const name = typeof input === 'string' ? input.split('/').at(-1)! : input.name || 'file' const localId = crypto.randomUUID() liveStore.getState().addAttachments(workspaceId, sessionId, [ { kind: 'file', localId, - name: typeof input === 'string' ? input.split('/').at(-1)! : input.name || 'file', + name, mediaType: file?.type || 'application/octet-stream', previewUrl: file?.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, status: 'uploading' @@ -47,7 +49,8 @@ async function stageFile( }) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Upload failed' - liveStore.getState().updateAttachment(workspaceId, localId, { status: 'error', error: message }) + liveStore.getState().removeAttachment(workspaceId, localId) + toast.add({ title: `Couldn’t add ${name}`, description: message, type: 'error' }) onError?.(message) } } @@ -100,8 +103,7 @@ export function stageDrawingDraft({ store.updateAttachment(workspaceId, localId, { previewUrl, status: 'draft', - upload: undefined, - error: undefined + upload: undefined }) } else { store.addAttachments(workspaceId, sessionId, [ @@ -148,8 +150,7 @@ export async function stageDrawing({ store.updateAttachment(workspaceId, localId, { previewUrl, status: 'uploading', - upload: undefined, - error: undefined + upload: undefined }) } else { store.addAttachments(workspaceId, sessionId, [ @@ -174,8 +175,7 @@ export async function stageDrawing({ liveStore.getState().updateAttachment(workspaceId, localId, { status: 'ready', upload, - mediaType: upload.mediaType, - error: undefined + mediaType: upload.mediaType }) } catch { if (!isCurrent()) return diff --git a/client/features/chat/composer/attachments/types.ts b/client/features/chat/composer/attachments/types.ts index e981e3c7..2e6b01c9 100644 --- a/client/features/chat/composer/attachments/types.ts +++ b/client/features/chat/composer/attachments/types.ts @@ -11,9 +11,8 @@ type UploadAttachmentBase = { name: string mediaType: string previewUrl?: string - status: 'draft' | 'uploading' | 'ready' | 'error' + status: 'draft' | 'uploading' | 'ready' upload?: UploadInfo - error?: string } export type DrawingPurpose = 'annotation' | 'sketch' @@ -29,5 +28,5 @@ export type ChatAttachment = | { kind: 'text'; localId: string; name: string; attachment: TextAttachment } export type ChatAttachmentPatch = Partial< - Pick + Pick > diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md index 3fa676d9..3bb3c97c 100644 --- a/workspace/.claude/skills/moi-workspace/references/INTENTS.md +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -106,8 +106,8 @@ Call from user event handlers. Text is captured immediately; workspace files are the server reads them during staging. Later changes do not update the attachment. Text appears as a labelled chip; files use the existing file or image representation. -Invalid arguments reject the call without staging anything. File preparation failures show a -removable error chip. File loading blocks sending until it finishes. Errors are recorded in +Invalid arguments reject the call without staging anything. File preparation failures remove +the attachment and show an error toast. File loading blocks sending until it finishes. Errors are recorded in `moi debug logs`. Identical text from the same applet with the same label is skipped. Attachments stay with their From 5a8863285ab5d36a323c799836ec5315b99557cb Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 18:27:04 +0200 Subject: [PATCH 15/24] Update attachment preparation test expectations --- .../attachments/text-attachments.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/client/features/chat/composer/attachments/text-attachments.test.ts b/client/features/chat/composer/attachments/text-attachments.test.ts index 00992e1d..6c1d9041 100644 --- a/client/features/chat/composer/attachments/text-attachments.test.ts +++ b/client/features/chat/composer/attachments/text-attachments.test.ts @@ -4,7 +4,7 @@ import { stageTextAttachment, stageChatAttachment } from './draft-attachments' import { attachmentKey, liveStore } from '../../chat-store' import { attachmentsForSend, - attachmentPartsForOptimisticTurn, + prepareDraftAttachments, withAttachmentDirectives } from '../../chat-send' @@ -89,6 +89,7 @@ describe('text staging and sends', () => { localId: 'image', name: 'Annotation.png', mediaType: 'image/png', + previewUrl: 'blob:annotation', status: 'ready', upload: { id: 'up', @@ -101,9 +102,20 @@ describe('text staging and sends', () => { ]) const ready = attachmentsForSend(workspaceId, null) expect(ready).toHaveLength(2) - expect(attachmentPartsForOptimisticTurn(ready)[0]).toEqual({ - type: 'text-attachment', - ...attachment + expect(prepareDraftAttachments(ready)).toEqual({ + attachments: [ + { type: 'text', ...attachment }, + { type: 'upload', uploadId: 'up' } + ], + parts: [ + { type: 'text-attachment', ...attachment }, + { + type: 'file-attachment', + filename: 'Annotation.png', + mediaType: 'image/png', + url: 'blob:annotation' + } + ] }) expect(withAttachmentDirectives(undefined, ready)?.directives).toEqual([ 'Annotation attachment sources in attachment order: 1. "view:orders".' From 47361ee8f0072ab3eccd13f2e631873ce8d493ad Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 18:27:13 +0200 Subject: [PATCH 16/24] Unify chat attachment preparation and uploads --- client/features/chat/chat-send.ts | 107 ++++++++++-------- client/features/chat/chat-store.ts | 35 +++--- .../composer/attachments/AttachmentChip.tsx | 41 +++---- .../attachments/FileAttachmentChip.tsx | 2 +- .../composer/attachments/draft-attachments.ts | 56 ++------- .../chat/composer/attachments/uploads.ts | 49 +++----- client/features/chat/useChat.ts | 11 +- .../features/views/useViewBuilderActions.ts | 8 +- 8 files changed, 126 insertions(+), 183 deletions(-) diff --git a/client/features/chat/chat-send.ts b/client/features/chat/chat-send.ts index ae81c720..484fdbcd 100644 --- a/client/features/chat/chat-send.ts +++ b/client/features/chat/chat-send.ts @@ -1,4 +1,4 @@ -import type { MessageAttachment } from '@/lib/types' +import type { MessageAttachment, TextAttachment, UploadInfo } from '@/lib/types' import type { AppletChatAttachment } from '@/client/features/applets/applet-runtime' import { uploadChatFile } from './composer/attachments/uploads' import { textAttachmentParts } from '@/lib/moi-attachments' @@ -24,32 +24,68 @@ export type ChatSendOptions = MoiUserMessageOptions & { preparedAttachments?: PreparedChatAttachments } +// Both send paths derive the wire reference and optimistic display together. +function prepareAttachment( + input: TextAttachment | UploadInfo, + previewUrl = '' +): { attachment: MessageAttachment; part: Part } { + if ('id' in input) { + return { + attachment: { type: 'upload', uploadId: input.id }, + part: { + type: 'file-attachment', + mediaType: input.mediaType, + filename: input.filename, + url: input.kind === 'image' ? previewUrl : '' + } + } + } + const { source, label, text } = input + return { + attachment: { type: 'text', source, label, text }, + part: textAttachmentParts([input])[0] + } +} + +function collectPreparedAttachments( + items: ReturnType[] +): PreparedChatAttachments { + return { + attachments: items.map(item => item.attachment), + parts: items.map(item => item.part) + } +} + export async function prepareChatAttachments( workspaceId: string, inputs: readonly AppletChatAttachment[] ): Promise { - const prepared = await Promise.all( - inputs.map(async input => { - if (input.type === 'text') { - const { source, label, text } = input - const attachment: MessageAttachment = { type: 'text', source, label, text } - return { attachment, part: textAttachmentParts([attachment])[0] } - } - const upload = await uploadChatFile(workspaceId, input.file ?? input.path) - const attachment: MessageAttachment = { type: 'upload', uploadId: upload.id } - const part: Part = { - type: 'file-attachment', - mediaType: upload.mediaType, - filename: upload.filename, - url: upload.kind === 'image' ? `/api/workspaces/${workspaceId}/uploads/${upload.id}` : '' - } - return { attachment, part } + return collectPreparedAttachments( + await Promise.all( + inputs.map(async input => { + if (input.type === 'text') return prepareAttachment(input) + const upload = await uploadChatFile(workspaceId, input.file ?? input.path) + return prepareAttachment(upload, `/api/workspaces/${workspaceId}/uploads/${upload.id}`) + }) + ) + ) +} + +export function prepareDraftAttachments( + attachments: readonly ChatAttachment[] +): PreparedChatAttachments { + return collectPreparedAttachments( + attachments.flatMap(attachment => { + if (attachment.kind === 'text') return [prepareAttachment(attachment.attachment)] + if (!attachment.upload) return [] + return [ + prepareAttachment( + { ...attachment.upload, filename: attachment.name, mediaType: attachment.mediaType }, + attachment.previewUrl + ) + ] }) ) - return { - attachments: prepared.map(item => item.attachment), - parts: prepared.map(item => item.part) - } } // Whether this send owns what the user has staged in the composer. Only a send @@ -72,26 +108,6 @@ export function attachmentsForSend( return pending.filter(a => a.kind === 'text' || (a.status === 'ready' && a.upload)) } -export function attachmentPartsForOptimisticTurn(attachments: readonly ChatAttachment[]): Part[] { - return attachments.map(attachment => { - if (attachment.kind === 'text') return textAttachmentParts([attachment.attachment])[0] - if (attachment.upload?.kind === 'image' && attachment.previewUrl) { - return { - type: 'file-attachment', - mediaType: attachment.mediaType, - url: attachment.previewUrl, - filename: attachment.name - } - } - return { - type: 'file-attachment', - mediaType: attachment.mediaType, - url: '', - filename: attachment.name - } - }) -} - export function withAttachmentDirectives( options: ChatSendOptions | undefined, attachments: readonly ChatAttachment[] @@ -220,12 +236,3 @@ export function resolveChatRunOptions( stream } } - -export function messageAttachmentsForSend( - attachments: readonly ChatAttachment[] -): MessageAttachment[] { - return attachments.flatMap(attachment => { - if (attachment.kind === 'text') return [{ type: 'text', ...attachment.attachment }] - return attachment.upload ? [{ type: 'upload', uploadId: attachment.upload.id }] : [] - }) -} diff --git a/client/features/chat/chat-store.ts b/client/features/chat/chat-store.ts index 71208dd8..ecb2cdfb 100644 --- a/client/features/chat/chat-store.ts +++ b/client/features/chat/chat-store.ts @@ -119,6 +119,19 @@ export type LiveStore = { sweepPreviews: (maxAgeMs: number, now: number) => void } +// Stable IDs keep asynchronous attachment work attached to a renamed session. +export function findAttachment( + attachments: LiveStore['attachments'], + workspaceId: string, + localId: string +) { + for (const [key, items] of Object.entries(attachments)) { + if (!key.startsWith(`${workspaceId}:`)) continue + const attachment = items.find(item => item.localId === localId) + if (attachment) return { key, attachment } + } +} + export const liveStore = createStore()(set => ({ activity: {}, errors: {}, @@ -189,15 +202,11 @@ export const liveStore = createStore()(set => ({ updateAttachment: (workspaceId, localId, patch) => set(s => { - const k = Object.keys(s.attachments).find( - k => k.startsWith(`${workspaceId}:`) && s.attachments[k].some(a => a.localId === localId) - ) - if (!k) return {} + const found = findAttachment(s.attachments, workspaceId, localId) + if (!found) return {} + const { key: k, attachment: target } = found const list = s.attachments[k] - if (!list) return {} - const target = list.find(attachment => attachment.localId === localId) if ( - target && target.kind !== 'text' && target.previewUrl && 'previewUrl' in patch && @@ -215,15 +224,11 @@ export const liveStore = createStore()(set => ({ removeAttachment: (workspaceId, localId) => set(s => { - const k = Object.keys(s.attachments).find( - k => k.startsWith(`${workspaceId}:`) && s.attachments[k].some(a => a.localId === localId) - ) - if (!k) return {} + const found = findAttachment(s.attachments, workspaceId, localId) + if (!found) return {} + const { key: k, attachment: target } = found const list = s.attachments[k] - if (!list) return {} - const target = list.find(a => a.localId === localId) - if (target && target.kind !== 'text' && target.previewUrl) - URL.revokeObjectURL(target.previewUrl) + if (target.kind !== 'text' && target.previewUrl) URL.revokeObjectURL(target.previewUrl) return { attachments: { ...s.attachments, [k]: list.filter(a => a.localId !== localId) } } }), diff --git a/client/features/chat/composer/attachments/AttachmentChip.tsx b/client/features/chat/composer/attachments/AttachmentChip.tsx index b787c858..b3458c00 100644 --- a/client/features/chat/composer/attachments/AttachmentChip.tsx +++ b/client/features/chat/composer/attachments/AttachmentChip.tsx @@ -8,7 +8,7 @@ type AttachmentChipProps = ComponentPropsWithRef<'div'> & { icon: Icon onRemove?: () => void previewUrl?: string - status?: 'draft' | 'uploading' | 'ready' + loading?: boolean } // Two layouts share one frame: a labelled chip or an image thumbnail. @@ -18,7 +18,7 @@ export function AttachmentChip({ icon, onRemove, previewUrl, - status, + loading, className, children, ...props @@ -32,24 +32,24 @@ export function AttachmentChip({ className )} > + {loading && ( +
+ +
+ )} {previewUrl ? ( - + ) : ( - + )} {children} ) } -type LabelChipContentProps = Pick +type LabelChipContentProps = Pick -function LabelChipContent({ label, icon: IconComponent, onRemove, status }: LabelChipContentProps) { +function LabelChipContent({ label, icon: IconComponent, onRemove }: LabelChipContentProps) { return ( <> {onRemove ? ( @@ -70,20 +70,18 @@ function LabelChipContent({ label, icon: IconComponent, onRemove, status }: Labe )} {label} - ) } -type ImageChipContentProps = Pick & { +type ImageChipContentProps = Pick & { previewUrl: string } -function ImageChipContent({ label, previewUrl, onRemove, status }: ImageChipContentProps) { +function ImageChipContent({ label, previewUrl, onRemove }: ImageChipContentProps) { return ( <> {label} - {onRemove && (