Skip to content

Commit 0f287b4

Browse files
baryhuangclaude
andcommitted
feat: link agent-uploaded files to thread response messages
When an agent uploads files via workspace_write_file MCP tool, those files now appear as attachments on the agent's response message in the thread UI. Previously files only appeared in the Files panel. Changes: - Base adapter: track uploaded files per channel, attach on _send_response() - Claude adapter: query for uploaded files before sending final response - Backend: add channel_name/uploaded_by filters to GET /v1/files - Frontend: expand isPreviewable() to include markdown, text, and code files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4cbc9fa commit 0f287b4

7 files changed

Lines changed: 99 additions & 143 deletions

File tree

Lines changed: 35 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,52 @@
1-
# Feature: Artifact Panel & Agent File Return
1+
# Feature: Agent File Return in Thread Messages
22

33
## Problem Statement
44

5-
The workspace thread UI currently has two gaps in file handling:
5+
When an agent uploads a file to the workspace (via `workspace_write_file` MCP tool), the file appears in the Files panel but is **not linked to the agent's chat response message**. The file upload and the response message are completely disconnected events.
66

7-
1. **Agents don't return files in thread messages.** When an agent generates content (markdown documents, code files, HTML), it dumps everything into `payload.content` as inline text. The plumbing for file attachments exists end-to-end (SDK → backend → frontend), but nothing in the agent orchestration layer creates files and attaches them to responses.
7+
Additionally, the thread UI only treats images and HTML as previewable — markdown, code, and text files show as plain download links.
88

9-
2. **No artifact/canvas side panel.** When a file attachment does exist on a message, clicking it navigates away from the chat (`setViewMode('files')`). There is no way to view a file alongside the conversation, unlike Claude Desktop's artifact panel.
9+
## What Was Implemented
1010

11-
## Current Architecture
11+
### 1. File tracking in base adapter (`sdk/src/openagents/adapters/base.py`)
1212

13-
### How thread messages work (end-to-end)
13+
- Added `_channel_uploaded_files` dict to track files uploaded during message handling
14+
- Added `track_uploaded_file(channel, file_info)` method
15+
- Modified `_send_response()` to pop tracked files and pass them as `attachments` to `client.send_message()`
1416

15-
```
16-
User sends message
17-
→ ChatInput → workspaceApi.sendMessage()
18-
→ POST /v1/events { type: "workspace.message.posted", source: "human:user" }
19-
→ Backend Pipeline:
20-
1. AuthMod (verify token)
21-
2. WorkspaceMod (route: parse @mentions, LLM router picks target agent)
22-
3. PersistenceMod (save EventRecord to DB)
23-
→ Agent polls GET /v1/events (every ~1s via AgentRunner._async_loop)
24-
→ react() → orchestrate_agent() → LLM + tools
25-
→ Agent posts response: WorkspaceClient.send_message()
26-
→ POST /v1/events { type: "workspace.message.posted", source: "openagents:agent-name" }
27-
→ Same pipeline → persisted to DB
28-
→ Frontend polls GET /v1/events?after={cursor} (every 2s)
29-
→ eventToMessage() → renders in chat via ChatMessage component
30-
```
31-
32-
### How file uploads work
33-
34-
**User upload (works today):**
35-
1. Upload file → `POST /v1/files` (multipart) → returns `{ id, filename, contentType, size }`
36-
2. Message event includes `payload.attachments: [{ fileId, filename, contentType, url }]`
37-
3. Frontend `Attachments` component renders attachment in thread
38-
4. URL regenerated from `fileId` at render time via `workspaceApi.getFileUrl(fileId)`
39-
40-
**Agent upload (plumbing exists, unused):**
41-
1. `POST /v1/files/base64` — JSON endpoint designed for agents (base64-encoded content)
42-
2. Returns same `{ id, filename, contentType, size }`
43-
3. `WorkspaceClient.send_message()` accepts `attachments` parameter — never called with it
44-
4. Agent orchestrator puts all output in `payload.content` as plain text
45-
46-
### Message attachment structure
47-
48-
```typescript
49-
// In event payload
50-
payload: {
51-
content: "Here's the report",
52-
attachments: [
53-
{ fileId: "uuid", filename: "report.md", contentType: "text/markdown", url: "..." }
54-
]
55-
}
56-
57-
// Extracted into message
58-
message.metadata.attachments = [{ fileId, filename, contentType, url }]
59-
```
60-
61-
### Thread UI attachment rendering (chat-message.tsx)
62-
63-
- **Images**: inline thumbnail, click → navigates to files view
64-
- **HTML files**: eye icon button, click → navigates to files view
65-
- **Other files**: download link (`<a href={url}>`)
66-
- **Markdown**: not treated as previewable (only images and HTML pass `isPreviewable()`)
67-
68-
### Files view (file-preview.tsx)
69-
70-
Full-page preview supporting HTML (iframe), images, markdown (MarkdownContent), text/code (pre block). Replaces the chat view entirely when `viewMode` switches to `'files'`.
71-
72-
### Existing split panel precedent
73-
74-
The browser view already supports a split mode: chat on left + browser on right (50/50). This is toggled via `splitBrowser` state in `LayoutContext` and persisted to localStorage.
75-
76-
## Identified Gaps
17+
### 2. File collection in Claude adapter (`sdk/src/openagents/adapters/claude.py`)
7718

78-
| Gap | Current State | What's Needed |
79-
|-----|--------------|---------------|
80-
| Agent file creation | Agent can upload via `/v1/files/base64` but never does | Agent orchestrator should upload generated files and attach metadata to response events |
81-
| Markdown not previewable in threads | `isPreviewable()` only returns true for images and HTML | Add markdown, code, SVG to previewable types |
82-
| No side panel for artifacts | Clicking attachment navigates away from chat to files view | Add split artifact panel alongside chat (reuse browser split pattern) |
83-
| No inline artifact detection | Long code/markdown blocks live inside message content | Detect fenced code blocks and offer "Open in panel" action |
84-
| File preview is full-page only | `file-preview.tsx` replaces chat entirely | Need a panel-mode variant that coexists with chat |
19+
- Added `_collect_uploaded_files(channel)` method that queries `GET /v1/files` for files uploaded by this agent to this channel
20+
- Tracks already-attached file IDs in `_attached_file_ids` to avoid duplicates across responses
21+
- Called before sending the final response
8522

86-
## Implementation Plan
23+
### 3. Backend: filter support for file listing (`workspace/backend/app/routers/files.py`)
8724

88-
### Phase 1: Agent File Return (Backend/SDK)
25+
- Added optional `channel_name` and `uploaded_by` query params to `GET /v1/files`
26+
- Updated `workspace_client.list_files()` to pass these filters
8927

90-
**Goal:** When an agent generates substantial content, upload it as a file and attach to the response message.
28+
### 4. Frontend: expanded previewable types (`chat-message.tsx`)
9129

92-
Key files:
93-
- `sdk/src/openagents/agents/runner.py` — agent orchestration loop
94-
- `sdk/src/openagents/client/workspace_client.py``send_message()` and file upload methods
30+
- `isPreviewable()` now returns true for markdown, text, and common code file types
31+
- Affected files: `packages/go/` and `workspace/frontend/` (kept in sync)
9532

96-
Changes:
97-
- In the agent orchestration layer, after LLM generates a response, detect substantial content blocks (markdown docs, code files, HTML)
98-
- Upload via `POST /v1/files/base64` with `source: "openagents:{agent-name}"`
99-
- Include attachment metadata in the `send_message()` call
100-
- Keep the inline `content` as a summary/reference, not the full file
33+
## Architecture (unchanged)
10134

102-
### Phase 2: Artifact Side Panel (Frontend)
103-
104-
**Goal:** View files alongside the conversation, like Claude Desktop's artifact panel.
105-
106-
Key files:
107-
- `workspace/frontend/components/layout/layout-context.tsx` — add `splitArtifact` state
108-
- `workspace/frontend/components/layout/wrapper.tsx` — add split layout variant
109-
- `workspace/frontend/components/chat/chat-message.tsx` — open attachments in panel instead of navigating away
110-
- New: `workspace/frontend/components/artifacts/artifact-panel.tsx` — panel component
111-
112-
Changes:
113-
- Add `artifactPanel: { fileId: string } | null` state to LayoutContext
114-
- Reuse the existing 50/50 split pattern from `splitBrowser`
115-
- When clicking an attachment in a thread message, set `artifactPanel` instead of `setViewMode('files')`
116-
- Panel renders file content (reuse rendering logic from `file-preview.tsx`)
117-
118-
### Phase 3: Inline Artifact Detection
119-
120-
**Goal:** Detect code fences in agent messages and offer "Open in panel" button.
121-
122-
Key files:
123-
- `workspace/frontend/components/chat/markdown-content.tsx` — add action buttons to code blocks
124-
- `workspace/frontend/components/chat/chat-message.tsx` — coordinate with artifact panel
125-
126-
Changes:
127-
- In MarkdownContent, add an "Open in panel" button on large fenced code blocks
128-
- Clicking creates a transient artifact (not persisted to files) and opens in side panel
129-
- Optionally: "Save as file" action to persist to `/v1/files`
130-
131-
### Phase 4: Rich Artifact Features (Future)
132-
133-
- Edit-in-panel for markdown/code
134-
- Version history (multiple iterations of same artifact)
135-
- Mermaid diagram rendering
136-
- Live-updating artifacts (agent streams, panel updates)
35+
```
36+
Agent uses workspace_write_file MCP tool
37+
→ POST /v1/files/base64 (uploads file)
38+
→ File appears in Files panel
39+
40+
Agent finishes processing
41+
→ _collect_uploaded_files() queries GET /v1/files?channel_name=X&uploaded_by=openagents:Y
42+
→ Files tracked via track_uploaded_file()
43+
→ _send_response() includes attachments in message event
44+
→ Frontend renders attachments in thread with eye icon (previewable)
45+
→ Click opens file preview (navigates to files view)
46+
```
13747

138-
## Key Files Reference
48+
## Future Work
13949

140-
| File | Role |
141-
|------|------|
142-
| `workspace/frontend/components/chat/chat-message.tsx` | Thread message + attachment rendering |
143-
| `workspace/frontend/components/chat/chat-input.tsx` | Message composition + file upload |
144-
| `workspace/frontend/components/chat/chat-view.tsx` | Main chat view, handles send flow |
145-
| `workspace/frontend/components/chat/markdown-content.tsx` | Markdown rendering in messages |
146-
| `workspace/frontend/components/files/file-preview.tsx` | Full-page file preview |
147-
| `workspace/frontend/components/layout/layout-context.tsx` | UI state (viewMode, splitBrowser) |
148-
| `workspace/frontend/components/layout/wrapper.tsx` | Layout orchestrator |
149-
| `workspace/frontend/hooks/use-polling.ts` | Message polling |
150-
| `workspace/frontend/lib/api.ts` | API client (sendMessage, uploadFile, getFileUrl) |
151-
| `workspace/frontend/lib/types.ts` | Types + eventToMessage conversion |
152-
| `workspace/backend/app/routers/files.py` | File upload/download/list endpoints |
153-
| `workspace/backend/app/routers/events.py` | Event posting + polling endpoints |
154-
| `workspace/backend/app/mods/workspace_mod.py` | Message routing (LLM router) |
155-
| `sdk/src/openagents/agents/runner.py` | Agent loop + orchestration |
156-
| `sdk/src/openagents/client/workspace_client.py` | Agent-side API client |
50+
- **Artifact side panel**: View files alongside the conversation (canvas-like, reuse browser split pattern)
51+
- **Inline artifact detection**: Detect large code blocks in message content and offer "Open in panel"
52+
- **Orchestrator path**: `SimpleAutoAgent` still doesn't send response messages — separate issue

packages/go/components/chat/chat-message.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface Attachment {
2222
function isPreviewable(contentType: string, filename: string): boolean {
2323
if (contentType?.startsWith('image/')) return true;
2424
if (contentType === 'text/html' || /\.html?$/i.test(filename)) return true;
25+
if (contentType === 'text/markdown' || /\.mdx?$/i.test(filename)) return true;
26+
if (contentType?.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true;
2527
return false;
2628
}
2729

sdk/src/openagents/adapters/base.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ def __init__(
5151
# Per-channel task tracking for parallel execution
5252
self._channel_tasks: dict[str, asyncio.Task] = {}
5353
self._channel_queues: dict[str, list[dict]] = {}
54+
# Per-channel uploaded file tracking — files uploaded during message
55+
# handling are attached to the final response message.
56+
self._channel_uploaded_files: dict[str, list[dict]] = {}
5457

5558
# ------------------------------------------------------------------
5659
# Lifecycle
@@ -310,15 +313,26 @@ async def _send_status(self, channel: str, content: str):
310313
except Exception:
311314
pass
312315

316+
def track_uploaded_file(self, channel: str, file_info: dict):
317+
"""Track a file uploaded during message handling for later attachment.
318+
319+
Args:
320+
channel: Channel name where the file was uploaded.
321+
file_info: Dict with at least ``fileId``, ``filename``, ``contentType``.
322+
"""
323+
self._channel_uploaded_files.setdefault(channel, []).append(file_info)
324+
313325
async def _send_response(self, channel: str, content: str):
314-
"""Send a chat response to a channel."""
326+
"""Send a chat response to a channel, attaching any tracked files."""
327+
attachments = self._channel_uploaded_files.pop(channel, None)
315328
await self.client.send_message(
316329
workspace_id=self.workspace_id,
317330
channel_name=channel,
318331
token=self.token,
319332
content=content,
320333
sender_type="agent",
321334
sender_name=self.agent_name,
335+
attachments=attachments or None,
322336
)
323337

324338
async def _send_error(self, channel: str, error: str):

sdk/src/openagents/adapters/claude.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
self._channel_sessions: dict[str, str] = {} # channel_name → Claude CLI session_id
4949
self._current_process: Optional[asyncio.subprocess.Process] = None
5050
self._channel_processes: dict[str, asyncio.subprocess.Process] = {} # channel → subprocess
51+
self._attached_file_ids: set[str] = set() # files already attached to responses
5152
self._sessions_file = (
5253
Path.home() / ".openagents" / "sessions"
5354
/ f"{workspace_id}_{agent_name}.json"
@@ -127,6 +128,33 @@ async def _stop_current_process(self):
127128
except Exception:
128129
pass
129130

131+
async def _collect_uploaded_files(self, channel: str):
132+
"""Query for files uploaded by this agent to the channel and track them."""
133+
try:
134+
result = await self.client.list_files(
135+
workspace_id=self.workspace_id,
136+
token=self.token,
137+
channel_name=channel,
138+
uploaded_by=f"openagents:{self.agent_name}",
139+
limit=20,
140+
)
141+
files = result.get("files", [])
142+
for f in files:
143+
file_id = f.get("id")
144+
if not file_id:
145+
continue
146+
# Skip files already attached in previous responses
147+
if file_id in self._attached_file_ids:
148+
continue
149+
self._attached_file_ids.add(file_id)
150+
self.track_uploaded_file(channel, {
151+
"fileId": file_id,
152+
"filename": f.get("filename", ""),
153+
"contentType": f.get("content_type", "application/octet-stream"),
154+
})
155+
except Exception as e:
156+
logger.debug(f"Failed to collect uploaded files for channel {channel}: {e}")
157+
130158
def _build_claude_cmd(self, prompt: str, channel_name: str) -> list[str]:
131159
"""Build the claude CLI command for a specific channel."""
132160
# On Windows, prefer .cmd/.exe wrappers over bare npm bash shims
@@ -524,6 +552,10 @@ async def _handle_message(self, msg: dict):
524552
if stderr_text:
525553
logger.warning(f"CLI stderr: {stderr_text[:300]}")
526554

555+
# Collect files uploaded by this agent during processing
556+
# and attach them to the final response message.
557+
await self._collect_uploaded_files(msg_channel)
558+
527559
# Post the final response. last_response_text holds text
528560
# from the last assistant turn (after all tool calls).
529561
# If posted_thinking is True, the last text was already

sdk/src/openagents/client/workspace_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,8 @@ async def list_files(
604604
token: str,
605605
limit: int = 50,
606606
offset: int = 0,
607+
channel_name: Optional[str] = None,
608+
uploaded_by: Optional[str] = None,
607609
) -> dict:
608610
"""List files via GET /v1/files."""
609611
import aiohttp
@@ -612,6 +614,10 @@ async def list_files(
612614
"limit": limit,
613615
"offset": offset,
614616
}
617+
if channel_name:
618+
params["channel_name"] = channel_name
619+
if uploaded_by:
620+
params["uploaded_by"] = uploaded_by
615621
async with aiohttp.ClientSession() as session:
616622
async with session.get(
617623
f"{self.endpoint}/v1/files",

workspace/backend/app/routers/files.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ async def upload_file_base64(
223223
async def list_files(
224224
network: str = Query(..., description="Network (workspace) ID or slug"),
225225
status: str = Query("active", description="Filter by status"),
226+
channel_name: Optional[str] = Query(None, description="Filter by channel name"),
227+
uploaded_by: Optional[str] = Query(None, description="Filter by uploader (e.g. openagents:agent-name)"),
226228
limit: int = Query(50, ge=1, le=200),
227229
offset: int = Query(0, ge=0),
228230
x_workspace_token: Optional[str] = Header(None),
@@ -241,10 +243,12 @@ async def list_files(
241243
select(FileRecord)
242244
.where(FileRecord.workspace_id == str(workspace.id))
243245
.where(FileRecord.status == status)
244-
.order_by(FileRecord.created_at.desc())
245-
.offset(offset)
246-
.limit(limit)
247246
)
247+
if channel_name:
248+
query = query.where(FileRecord.channel_name == channel_name)
249+
if uploaded_by:
250+
query = query.where(FileRecord.uploaded_by == uploaded_by)
251+
query = query.order_by(FileRecord.created_at.desc()).offset(offset).limit(limit)
248252
rows = db.execute(query).scalars().all()
249253

250254
total = db.execute(

workspace/frontend/components/chat/chat-message.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface Attachment {
2222
function isPreviewable(contentType: string, filename: string): boolean {
2323
if (contentType?.startsWith('image/')) return true;
2424
if (contentType === 'text/html' || /\.html?$/i.test(filename)) return true;
25+
if (contentType === 'text/markdown' || /\.mdx?$/i.test(filename)) return true;
26+
if (contentType?.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true;
2527
return false;
2628
}
2729

0 commit comments

Comments
 (0)