Skip to content

Commit 6e3f24d

Browse files
committed
feat: file attachments in WebUI (attach button + drag-and-drop)
Adds file attachment support to the WebUI, consistent with the CLI --ctx flag and @ref resolution: - Paperclip button next to the input opens file picker - Drag and drop files anywhere on the messages area - Attached files show as styled chips above the input - Individual file removal via chip ✕ button - 5 MB per-file limit, 10 MB total limit - On send, file content is prepended as context blocks before the prompt - Visual drag-over state with dashed outline + overlay - Startup message updated to mention file attachment No server changes needed — files are read client-side via FileReader API and injected directly into the prompt text.
1 parent 480a362 commit 6e3f24d

2 files changed

Lines changed: 160 additions & 2 deletions

File tree

cmd/odek/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func serveCmd(args []string) error {
128128

129129
fmt.Fprintf(os.Stderr, "odek serve ⚡ http://%s\n", listener.Addr())
130130
fmt.Fprintf(os.Stderr, " WebSocket: ws://%s/ws\n", listener.Addr())
131-
fmt.Fprintf(os.Stderr, " Type @ in the input to reference files and sessions.\n")
131+
fmt.Fprintf(os.Stderr, " Type @ to reference files, drop or attach files inline.\n")
132132

133133
if openBrowser {
134134
openInBrowser("http://" + listener.Addr().String())

cmd/odek/ui/index.html

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
padding: 20px 24px;
106106
contain: content;
107107
scroll-behavior: smooth;
108+
position: relative;
108109
}
109110

110111
/* ── Message Bubbles ── */
@@ -869,6 +870,48 @@
869870
letter-spacing: 0.3px;
870871
}
871872

873+
/* ── File Attachments ── */
874+
#attach-btn {
875+
background: transparent; border: 1px solid var(--border-subtle); border-radius: 8px;
876+
color: var(--text-tertiary); cursor: pointer; font-size: 16px; line-height: 1;
877+
padding: 6px 8px; transition: all 0.15s; flex-shrink: 0;
878+
display: flex; align-items: center; justify-content: center;
879+
}
880+
#attach-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-subtle); }
881+
#attach-btn:active { transform: scale(0.95); }
882+
#attach-btn svg { width: 16px; height: 16px; }
883+
884+
#file-chips {
885+
display: flex; gap: 6px; flex-wrap: wrap; padding: 4px 12px 0;
886+
}
887+
.file-chip {
888+
display: inline-flex; align-items: center; gap: 4px;
889+
background: var(--accent-subtle); border: 1px solid rgba(56,189,248,0.15);
890+
border-radius: 6px; padding: 3px 8px; font-size: 11px; font-family: var(--font-mono);
891+
color: var(--accent); animation: chipEnter 0.2s ease-out;
892+
}
893+
@keyframes chipEnter {
894+
from { opacity: 0; transform: scale(0.9); }
895+
to { opacity: 1; transform: scale(1); }
896+
}
897+
.file-chip .chip-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
898+
.file-chip .chip-size { color: var(--text-tertiary); font-size: 10px; }
899+
.file-chip .chip-remove {
900+
cursor: pointer; opacity: 0.5; font-size: 14px; line-height: 1; padding: 0 2px;
901+
transition: opacity 0.1s;
902+
}
903+
.file-chip .chip-remove:hover { opacity: 1; color: #ef4444; }
904+
905+
/* Drag-over state */
906+
#messages.drag-over { outline: 2px dashed var(--accent); outline-offset: -8px; }
907+
#messages.drag-over::after {
908+
content: 'Drop files to attach'; position: absolute; inset: 0;
909+
display: flex; align-items: center; justify-content: center;
910+
font-size: 14px; color: var(--text-secondary);
911+
background: rgba(10,10,11,0.6); backdrop-filter: blur(4px);
912+
z-index: 100; pointer-events: none;
913+
}
914+
872915
/* Input area top border gradient */
873916
#input-area {
874917
border-top: none;
@@ -1046,8 +1089,13 @@ <h2>kode agent</h2>
10461089

10471090
<!-- Input -->
10481091
<div id="input-area">
1092+
<div id="file-chips"></div>
10491093
<div id="completion"></div>
10501094
<div id="input-row">
1095+
<button id="attach-btn" title="Attach files">
1096+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
1097+
</button>
1098+
<input type="file" id="file-input" multiple style="display:none"/>
10511099
<div id="input-wrap">
10521100
<textarea id="prompt" rows="1" placeholder="Ask odek to do something... (@ to reference)"></textarea>
10531101
</div>
@@ -1121,6 +1169,7 @@ <h3>Delete session?</h3>
11211169
let busy = false;
11221170
let history = JSON.parse(localStorage.getItem('kode_history') || '[]');
11231171
let historyIdx = -1;
1172+
let attachedFiles = []; // {name, size, content}
11241173

11251174
// ── DOM ──
11261175
const messagesEl = document.getElementById('messages');
@@ -1886,7 +1935,17 @@ <h3>Delete session?</h3>
18861935

18871936
// ── Send ──
18881937
function send() {
1889-
const text = promptEl.value.trim();
1938+
let text = promptEl.value.trim();
1939+
1940+
// Prepend attached file content
1941+
if (attachedFiles.length > 0) {
1942+
const blocks = attachedFiles.map(f =>
1943+
'--- ' + f.name + ' (' + formatFileSize(f.size) + ') ---\n' + f.content + '\n--- end ' + f.name + ' ---'
1944+
);
1945+
text = blocks.join('\n\n') + '\n\n' + text;
1946+
clearAttachedFiles();
1947+
}
1948+
18901949
if (!text || busy || !ws || ws.readyState !== WebSocket.OPEN) return;
18911950

18921951
history.push(text);
@@ -1925,6 +1984,105 @@ <h3>Delete session?</h3>
19251984
}));
19261985
}
19271986

1987+
// ── File Attachments ──
1988+
const fileInput = document.getElementById('file-input');
1989+
const attachBtn = document.getElementById('attach-btn');
1990+
const fileChips = document.getElementById('file-chips');
1991+
1992+
function formatFileSize(bytes) {
1993+
if (bytes < 1024) return bytes + ' B';
1994+
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
1995+
return (bytes/(1024*1024)).toFixed(1) + ' MB';
1996+
}
1997+
1998+
function addAttachedFile(file) {
1999+
// Check total size (max 10MB total)
2000+
const totalSize = attachedFiles.reduce((s, f) => s + f.size, 0) + file.size;
2001+
if (totalSize > 10 * 1024 * 1024) {
2002+
showToast('Total attachment size exceeds 10 MB');
2003+
return;
2004+
}
2005+
attachedFiles.push(file);
2006+
renderFileChips();
2007+
}
2008+
2009+
function removeAttachedFile(index) {
2010+
attachedFiles.splice(index, 1);
2011+
renderFileChips();
2012+
}
2013+
2014+
function clearAttachedFiles() {
2015+
attachedFiles = [];
2016+
renderFileChips();
2017+
}
2018+
2019+
function renderFileChips() {
2020+
if (attachedFiles.length === 0) {
2021+
fileChips.innerHTML = '';
2022+
return;
2023+
}
2024+
fileChips.innerHTML = attachedFiles.map((f, i) =>
2025+
'<span class="file-chip">' +
2026+
'<span class="chip-name">' + escapeHtml(f.name) + '</span>' +
2027+
'<span class="chip-size">' + formatFileSize(f.size) + '</span>' +
2028+
'<span class="chip-remove" onclick="removeAttachedFile(' + i + ')">✕</span>' +
2029+
'</span>'
2030+
).join('');
2031+
}
2032+
2033+
function readFileAsText(file) {
2034+
return new Promise((resolve, reject) => {
2035+
// Limit individual files to 5MB
2036+
if (file.size > 5 * 1024 * 1024) {
2037+
reject(new Error('File too large (max 5 MB): ' + file.name));
2038+
return;
2039+
}
2040+
const reader = new FileReader();
2041+
reader.onload = () => resolve(reader.result);
2042+
reader.onerror = () => reject(reader.error);
2043+
reader.readAsText(file);
2044+
});
2045+
}
2046+
2047+
function handleFiles(fileList) {
2048+
const promises = [];
2049+
for (let i = 0; i < fileList.length; i++) {
2050+
const file = fileList[i];
2051+
promises.push(
2052+
readFileAsText(file).then(content => {
2053+
addAttachedFile({name: file.name, size: file.size, content});
2054+
}).catch(err => {
2055+
showToast(err.message);
2056+
})
2057+
);
2058+
}
2059+
return Promise.all(promises);
2060+
}
2061+
2062+
// Attach button
2063+
attachBtn.addEventListener('click', () => fileInput.click());
2064+
fileInput.addEventListener('change', () => {
2065+
handleFiles(fileInput.files);
2066+
fileInput.value = '';
2067+
});
2068+
2069+
// Drag and drop on messages area
2070+
messagesEl.addEventListener('dragover', (e) => {
2071+
e.preventDefault();
2072+
messagesEl.classList.add('drag-over');
2073+
});
2074+
messagesEl.addEventListener('dragleave', () => {
2075+
messagesEl.classList.remove('drag-over');
2076+
});
2077+
messagesEl.addEventListener('drop', (e) => {
2078+
e.preventDefault();
2079+
messagesEl.classList.remove('drag-over');
2080+
if (e.dataTransfer.files.length > 0) {
2081+
handleFiles(e.dataTransfer.files);
2082+
promptEl.focus();
2083+
}
2084+
});
2085+
19282086
// ── Input handlers ──
19292087
promptEl.addEventListener('keydown', (e) => {
19302088
if (e.key === 'Enter' && !e.shiftKey) {

0 commit comments

Comments
 (0)