Skip to content

Commit 757d704

Browse files
feat(composer): attachments system with file drag-and-drop
Attachments appear as chips above the composer input. Browser element extractions are added as attachments (not dumped in text). Files can be dragged and dropped onto the composer. Attachments are appended as code blocks to the message when sent, then cleared.
1 parent 6b9976a commit 757d704

4 files changed

Lines changed: 130 additions & 8 deletions

File tree

crates/tauri-app/frontend/src/components/browser/BrowserPanel.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,14 @@ export function BrowserPanel(props: Props) {
5555
break;
5656
case "extraction":
5757
if (p.html) {
58-
const ctx = `**Extracted from ${currentUrl()}**${p.selector ? ` \`${p.selector}\`` : ""}\n\n\`\`\`html\n${p.html}\n\`\`\`\n\n\`\`\`css\n${p.css || "{}"}\n\`\`\``;
59-
setStore("composerText", (prev: string) => prev ? prev + "\n\n" + ctx : ctx);
58+
const content = `<!-- From ${currentUrl()} ${p.selector || ""} -->\n${p.html}\n\n/* Computed styles */\n${p.css || "{}"}`;
59+
setStore("attachments", (prev) => [...prev, {
60+
id: crypto.randomUUID(),
61+
type: "extraction" as const,
62+
name: p.selector || "Element",
63+
content,
64+
language: "html",
65+
}]);
6066
}
6167
setInspecting(false);
6268
break;

crates/tauri-app/frontend/src/components/composer/Composer.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Show } from "solid-js";
1+
import { Show, For, createSignal } from "solid-js";
22
import { appStore } from "../../stores/app-store";
33
import { open } from "@tauri-apps/plugin-dialog";
44
import * as ipc from "../../ipc";
55
import { ModelSelector } from "./ModelSelector";
6+
import type { Attachment } from "../../types";
67

78
export function Composer() {
89
const { store, setStore, sendUserMessage } = appStore;
@@ -69,6 +70,35 @@ export function Composer() {
6970
}
7071
}
7172

73+
const [dragOver, setDragOver] = createSignal(false);
74+
75+
function removeAttachment(id: string) {
76+
setStore("attachments", (a) => a.filter((x) => x.id !== id));
77+
}
78+
79+
function handleDrop(e: DragEvent) {
80+
e.preventDefault();
81+
setDragOver(false);
82+
const files = e.dataTransfer?.files;
83+
if (!files) return;
84+
for (const file of Array.from(files)) {
85+
const reader = new FileReader();
86+
reader.onload = () => {
87+
const content = reader.result as string;
88+
const ext = file.name.split(".").pop() || "";
89+
const lang = { ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", py: "python", rs: "rust", go: "go", css: "css", html: "html", json: "json", md: "markdown", yaml: "yaml", yml: "yaml", toml: "toml", sh: "bash" }[ext] || "";
90+
setStore("attachments", (prev) => [...prev, {
91+
id: crypto.randomUUID(),
92+
type: "file" as const,
93+
name: file.name,
94+
content,
95+
language: lang,
96+
}]);
97+
};
98+
reader.readAsText(file);
99+
}
100+
}
101+
72102
const providerLabel = () =>
73103
store.selectedProvider === "claude_code" ? "Claude Code" : "Codex";
74104

@@ -93,7 +123,33 @@ export function Composer() {
93123
return (
94124
<Show when={isActive()}>
95125
<div class="composer-wrapper">
96-
<div class="composer-card">
126+
<div
127+
class="composer-card"
128+
classList={{ "drag-over": dragOver() }}
129+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
130+
onDragLeave={() => setDragOver(false)}
131+
onDrop={handleDrop}
132+
>
133+
<Show when={store.attachments.length > 0}>
134+
<div class="attachment-chips">
135+
<For each={store.attachments}>
136+
{(att) => (
137+
<div class="attachment-chip" classList={{ extraction: att.type === "extraction" }}>
138+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
139+
{att.type === "extraction"
140+
? <><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/></>
141+
: <><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></>
142+
}
143+
</svg>
144+
<span class="attachment-name">{att.name}</span>
145+
<button class="attachment-remove" onClick={() => removeAttachment(att.id)}>
146+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
147+
</button>
148+
</div>
149+
)}
150+
</For>
151+
</div>
152+
</Show>
97153
<div class="composer-input-row">
98154
<textarea
99155
class="composer-input"
@@ -181,6 +237,47 @@ if (!document.getElementById("composer-styles")) {
181237
border-color: var(--border-glow);
182238
box-shadow: 0 0 0 2px var(--primary-glow), 0 4px 16px rgba(0, 0, 0, 0.15);
183239
}
240+
.composer-card.drag-over {
241+
border-color: var(--primary);
242+
background: rgba(107, 124, 255, 0.04);
243+
}
244+
.attachment-chips {
245+
display: flex;
246+
flex-wrap: wrap;
247+
gap: 4px;
248+
padding-bottom: 4px;
249+
}
250+
.attachment-chip {
251+
display: flex;
252+
align-items: center;
253+
gap: 4px;
254+
padding: 3px 6px 3px 8px;
255+
background: var(--bg-muted);
256+
border: 1px solid var(--border);
257+
border-radius: var(--radius-sm);
258+
font-size: 11px;
259+
color: var(--text-secondary);
260+
}
261+
.attachment-chip.extraction {
262+
border-color: rgba(107, 124, 255, 0.2);
263+
background: rgba(107, 124, 255, 0.06);
264+
color: var(--primary);
265+
}
266+
.attachment-chip svg { flex-shrink: 0; }
267+
.attachment-name {
268+
max-width: 120px;
269+
overflow: hidden;
270+
text-overflow: ellipsis;
271+
white-space: nowrap;
272+
}
273+
.attachment-remove {
274+
width: 16px; height: 16px;
275+
display: flex; align-items: center; justify-content: center;
276+
border-radius: 3px;
277+
color: var(--text-tertiary);
278+
transition: background 0.1s, color 0.1s;
279+
}
280+
.attachment-remove:hover { background: var(--bg-accent); color: var(--text); }
184281
.composer-input-row {
185282
display: flex;
186283
align-items: flex-end;

crates/tauri-app/frontend/src/stores/app-store.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createRoot } from "solid-js";
22
import { createStore } from "solid-js/store";
3-
import type { Project, Thread, ChatMessage, SessionStatus, AgentEventPayload } from "../types";
3+
import type { Project, Thread, ChatMessage, SessionStatus, AgentEventPayload, Attachment } from "../types";
44
import * as ipc from "../ipc";
55

66
export interface PendingApproval {
@@ -38,6 +38,7 @@ export interface AppStore {
3838
threadBrowserUrls: Record<string, string>;
3939
autoNamingEnabled: boolean;
4040
namingInProgress: Record<string, boolean>;
41+
attachments: Attachment[];
4142
}
4243

4344
function createAppStore() {
@@ -69,6 +70,7 @@ function createAppStore() {
6970
threadBrowserUrls: {},
7071
autoNamingEnabled: true,
7172
namingInProgress: {},
73+
attachments: [],
7274
});
7375

7476
async function loadData() {
@@ -171,14 +173,23 @@ function createAppStore() {
171173

172174
async function sendUserMessage() {
173175
const text = store.composerText.trim();
174-
if (!text || !store.activeTab) return;
176+
const atts = [...store.attachments];
177+
if ((!text && atts.length === 0) || !store.activeTab) return;
175178

176179
const threadId = store.activeTab;
177180
setStore("composerText", "");
181+
setStore("attachments", []);
182+
183+
// Build full message: user text + attachment context blocks
184+
let fullText = text;
185+
for (const att of atts) {
186+
const lang = att.language || (att.type === "extraction" ? "html" : "");
187+
fullText += `\n\n--- Attached: ${att.name} ---\n\`\`\`${lang}\n${att.content}\n\`\`\``;
188+
}
178189

179190
try {
180-
const msgId = await ipc.persistUserMessage(threadId, text);
181-
const userMsg: ChatMessage = { id: msgId, thread_id: threadId, role: "user", content: text };
191+
const msgId = await ipc.persistUserMessage(threadId, fullText);
192+
const userMsg: ChatMessage = { id: msgId, thread_id: threadId, role: "user", content: fullText };
182193
setStore("threadMessages", threadId, (msgs) => [...(msgs || []), userMsg]);
183194

184195
const project = store.projects.find((p) => p.threads.some((t) => t.id === threadId));

crates/tauri-app/frontend/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export interface AgentEventPayload {
4646
model?: string;
4747
}
4848

49+
export interface Attachment {
50+
id: string;
51+
type: "file" | "extraction" | "image";
52+
name: string;
53+
content: string;
54+
language?: string;
55+
}
56+
4957
export type SessionStatus = "idle" | "starting" | "ready" | "generating" | "error";
5058

5159
export const THREAD_COLORS = [

0 commit comments

Comments
 (0)