* feat(#195): add chat attachment support for files and images - Add Attachment data model with computed properties (isImage, formattedSize, fileExtension) - Extend ChatMessage with optional attachments list - Add Coil 2.7.0 for async image loading in Compose - Extend ChatViewModel with pending attachment state, addAttachment/removeAttachment/clearAttachments - Build attachment-prefixed message text (data URIs for images, file refs for others) - Add paperclip button and file picker launcher (ActivityResultContracts.GetContent) to ChatScreen - Add animated attachment preview chips with thumbnails before sending - Render attachments inline in UserBubble (AsyncImage for images, file cards for others) - Add string resources for attachment UI features * fix(#195): rewrite attachment upload protocol to match Hermes backend RPC methods BREAKS: removes old buildAttachmentText() that embedded base64 data URIs in prompt text. Changes: - Replace inline data-URI encoding with proper backend RPC flow: - image.attach_bytes for images (fire-and-forget, auto-queues to session) - file.attach for non-image files (awaited, returns @file: ref text) - Add CompletableDeferred to PendingRequest so sendMessage can await RPC responses - Add readContentUriBytes() to read file content from content:// URIs - Add sendRpcAndAwait() suspend function for sequential RPC calls - Add RpcCallException for awaited RPC error handling - Suppress error UI for awaited RPCs (caller handles them directly) - Keep ChatScreen/ChatBubble UI layer unchanged (file picker, previews, inline images) * feat(#195): add camera capture support via FileProvider + TakePicture contract - Creates temporary photo files in cacheDir via FileProvider - Launches system camera with ActivityResultContracts.TakePicture - Adds camera icon button (📷) next to the paperclip in ChatInputBar - Captured photos added as image/jpeg attachments via same addAttachment flow - FileProvider configured with cache-root path in res/xml/file_paths.xml - Error handling for launch failures and capture failures with snackbar * refactor(#195): group camera/image/file into single attach dropdown - Replaces separate 📷 and 📎 buttons with a single 📎 button - DropdownMenu opens with three options: - 📷 Camera → TakePicture via FileProvider temp URI - 🖼️ Image → GetContent("image/*") - 📄 File → GetContent("*/*") - Removes onAttachClick, adds onImageTap/onFileTap callbacks - Input bar stays clean with one attachment affordance * fix(#195): add missing imports for CI compilation ChatScreen.kt: - android.util.Log - androidx.compose.animation.core.* (infiniteRepeatable, tween, LinearEasing, RepeatMode, rememberInfiniteTransition) - androidx.compose.foundation.layout.widthIn - androidx.compose.material.icons.filled.AttachFile - androidx.compose.material.icons.filled.InsertDriveFile - androidx.compose.ui.layout.ContentScale - coil.compose.AsyncImage ChatBubble.kt: - androidx.compose.material.icons.filled.InsertDriveFile * fix(#195): resolve android-lint LocalContextGetResourceValueCall error Replace context.getString(R.string.chat_camera_error) with a composable-level cameraErrorMsg captured via stringResource() to avoid querying resources via LocalContext.current in a callback. * fix(#195): migrate InsertDriveFile to AutoMirrored, fix chip double-remove - Replace deprecated Icons.Filled.InsertDriveFile with Icons.AutoMirrored.Filled.InsertDriveFile in ChatBubble.kt and ChatScreen.kt - Remove onClick from AttachmentChip Surface so only the dedicated close button triggers removal, not accidental taps on the chip body --------- Co-authored-by: Hermes Agent <hermes@agent.local>
401 lines
14 KiB
HTML
401 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ChatInputBar — Layout (v2)</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||
"Helvetica Neue", Arial, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
background: #111;
|
||
color: #eee;
|
||
padding: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 36px;
|
||
}
|
||
|
||
.label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: #888;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
background: rgba(30,30,35,0.92);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 24px;
|
||
padding: 4px 8px;
|
||
backdrop-filter: blur(8px);
|
||
position: relative;
|
||
}
|
||
|
||
.icon-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
color: #999;
|
||
transition: background 0.15s;
|
||
}
|
||
.icon-btn:hover { background: rgba(255,255,255,0.08); }
|
||
.icon-btn:disabled { opacity: 0.25; cursor: default; }
|
||
|
||
.send-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
background: var(--btn-bg, rgba(255,255,255,0.08));
|
||
color: var(--btn-color, #999);
|
||
}
|
||
.send-btn.mic { --btn-bg: rgba(255,255,255,0.08); --btn-color: #ccc; }
|
||
.send-btn.send { --btn-bg: #4f8cff; --btn-color: #fff; }
|
||
.send-btn.stop { --btn-bg: #e53935; --btn-color: #fff; }
|
||
|
||
.input-field {
|
||
flex: 1;
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: 20px;
|
||
background: transparent;
|
||
padding: 8px 14px;
|
||
font-size: 14px;
|
||
color: #eee;
|
||
outline: none;
|
||
min-height: 36px;
|
||
max-height: 120px;
|
||
transition: border-color 0.15s;
|
||
font-family: inherit;
|
||
line-height: 1.5;
|
||
}
|
||
.input-field:focus { border-color: #4f8cff; }
|
||
.input-field::placeholder { color: #666; }
|
||
|
||
.input-field:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.chips-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 4px 12px;
|
||
max-width: 500px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.chip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.10);
|
||
border-radius: 16px;
|
||
padding: 4px 6px 4px 10px;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
.chip:hover { background: rgba(255,255,255,0.10); }
|
||
.chip .thumb {
|
||
width: 20px; height: 20px;
|
||
border-radius: 4px;
|
||
background: #333;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
color: #888;
|
||
}
|
||
.chip .name {
|
||
font-size: 12px;
|
||
max-width: 100px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #ccc;
|
||
}
|
||
.chip .close {
|
||
width: 18px; height: 18px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
color: #888;
|
||
flex-shrink: 0;
|
||
}
|
||
.chip .close:hover { background: rgba(255,255,255,0.12); }
|
||
|
||
/* Dropdown menu */
|
||
.menu-wrapper {
|
||
position: relative;
|
||
}
|
||
.menu {
|
||
position: absolute;
|
||
bottom: 36px;
|
||
left: 0;
|
||
min-width: 180px;
|
||
background: rgba(42,42,50,0.98);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 12px;
|
||
padding: 4px 0;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
z-index: 10;
|
||
overflow: hidden;
|
||
}
|
||
.menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
border: none;
|
||
background: transparent;
|
||
color: #ddd;
|
||
font-size: 14px;
|
||
width: 100%;
|
||
text-align: left;
|
||
transition: background 0.1s;
|
||
font-family: inherit;
|
||
}
|
||
.menu-item:hover { background: rgba(255,255,255,0.06); }
|
||
.menu-item .icon { font-size: 18px; width: 24px; text-align: center; flex-shrink: 0; }
|
||
|
||
.variant { margin-bottom: 8px; }
|
||
|
||
.note {
|
||
font-size: 12px;
|
||
color: #777;
|
||
margin-top: 4px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.states {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
/* Simple arrow for "tap here" */
|
||
.tap-indicator {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: rgba(79,140,255,0.12);
|
||
color: #7aadff;
|
||
padding: 2px 10px;
|
||
border-radius: 8px;
|
||
font-size: 11px;
|
||
margin-left: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h2 style="color:#ddd; font-weight:500; font-size:18px; margin-bottom:4px;">📱 ChatInputBar (v2)</h2>
|
||
<p style="color:#777; font-size:13px; margin-bottom:20px;">
|
||
Single 📎 attach button → dropdown menu with Camera / Image / File
|
||
</p>
|
||
|
||
<div class="states">
|
||
|
||
<!-- 1. Empty + menu open -->
|
||
<div class="variant">
|
||
<div class="label">1. Empty — mic visible, dropdown open</div>
|
||
<div class="bar">
|
||
<div class="menu-wrapper">
|
||
<button class="icon-btn" title="Attach" style="background:rgba(255,255,255,0.08);">📎</button>
|
||
<div class="menu">
|
||
<button class="menu-item"><span class="icon">📷</span> Camera</button>
|
||
<button class="menu-item"><span class="icon">🖼️</span> Image</button>
|
||
<button class="menu-item"><span class="icon">📄</span> File</button>
|
||
</div>
|
||
</div>
|
||
<input class="input-field" placeholder="Type a message…" disabled />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn mic" title="Voice input">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">📎 button opens a Material 3 DropdownMenu with three options. Selecting one fires the launcher and closes the menu.</div>
|
||
</div>
|
||
|
||
<!-- 2. Empty, menu closed -->
|
||
<div class="variant">
|
||
<div class="label">2. Empty — normal state</div>
|
||
<div class="bar">
|
||
<button class="icon-btn" title="Attach">📎</button>
|
||
<input class="input-field" placeholder="Type a message…" disabled />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn mic" title="Voice input">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">Default look — single 📎, no clutter. Mic visible when input is blank & no attachments.</div>
|
||
</div>
|
||
|
||
<!-- 3. Typing -->
|
||
<div class="variant">
|
||
<div class="label">3. Typing — send shown</div>
|
||
<div class="bar">
|
||
<button class="icon-btn" title="Attach">📎</button>
|
||
<input class="input-field" placeholder="Type a message…" value="Deploy the new config, but keep the old one as backup" style="color:#eee;" />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn send" title="Send">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">Mic → Send (blue) when text or attachments present.</div>
|
||
</div>
|
||
|
||
<!-- 4. Attachments -->
|
||
<div class="variant">
|
||
<div class="label">4. With attachments — preview chips</div>
|
||
<div class="chips-row">
|
||
<div class="chip">
|
||
<span class="thumb">📷</span>
|
||
<span class="name">photo_20260629.jpg</span>
|
||
<button class="close">✕</button>
|
||
</div>
|
||
<div class="chip">
|
||
<span class="thumb">📄</span>
|
||
<span class="name">config.yaml</span>
|
||
<button class="close">✕</button>
|
||
</div>
|
||
<div class="chip">
|
||
<span class="thumb">📷</span>
|
||
<span class="name">screenshot.png</span>
|
||
<button class="close">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="bar" style="margin-top:0;">
|
||
<button class="icon-btn" title="Attach">📎</button>
|
||
<input class="input-field" placeholder="Type a message…" value="Here's what I'm looking at" style="color:#eee;" />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn send" title="Send">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">Image chips → AsyncImage thumbnail. File chips → file icon. Close ✕ removes.</div>
|
||
</div>
|
||
|
||
<!-- 5. Listening -->
|
||
<div class="variant">
|
||
<div class="label">5. Listening — stop shown</div>
|
||
<div class="bar">
|
||
<button class="icon-btn" title="Attach" disabled>📎</button>
|
||
<input class="input-field" placeholder="Type a message…" disabled />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn stop" title="Stop listening">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">While listening, input + attach disabled. Red Stop replaces Mic.</div>
|
||
</div>
|
||
|
||
<!-- 6. Disconnected -->
|
||
<div class="variant">
|
||
<div class="label">6. Disconnected — everything disabled</div>
|
||
<div class="bar" style="opacity:0.5;">
|
||
<button class="icon-btn" disabled title="Attach">📎</button>
|
||
<input class="input-field" placeholder="Not connected" disabled />
|
||
<div style="width:4px;flex-shrink:0;"></div>
|
||
<button class="send-btn mic" disabled title="Voice input">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="note">Everything dimmed, placeholder "Not connected".</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Full structural diagram -->
|
||
<hr style="border-color:rgba(255,255,255,0.06); margin: 28px 0 16px;" />
|
||
|
||
<h3 style="color:#ddd; font-weight:500; font-size:14px; margin-bottom:12px;">📐 Structural hierarchy</h3>
|
||
|
||
<pre style="font-size:12px; color:#aaa; line-height:1.6; background:rgba(255,255,255,0.03); padding:16px; border-radius:12px; max-width:600px;">
|
||
Card (roundedCornerShape(24dp), surface alpha 0.85)
|
||
└ Column
|
||
├ [conditional] Slash command suggestions
|
||
│ └ LazyColumn (maxHeight 200dp)
|
||
├ [conditional] Attachment preview chips
|
||
│ └ Row
|
||
│ └ AttachmentChip × N
|
||
│ └ Surface (roundedCorner 16dp)
|
||
│ ├ [if image] AsyncImage(20dp)
|
||
│ ├ [else] Icon(InsertDriveFile, 16dp)
|
||
│ └ name + Close IconButton(18dp)
|
||
└ Row (main input area)
|
||
├ Box (positioned for dropdown)
|
||
│ ├ IconButton 📎 AttachFile (36dp)
|
||
│ └ DropdownMenu (expanded = showAttachmentMenu)
|
||
│ ├ DropdownMenuItem: 📷 Camera → onCameraTap()
|
||
│ ├ DropdownMenuItem: 🖼️ Image → onImageTap() → launch("image/*")
|
||
│ └ DropdownMenuItem: 📄 File → onFileTap() → launch("*/*")
|
||
├ OutlinedTextField (weight=1f, maxLines=4, rounded 20dp)
|
||
├ Spacer(4dp)
|
||
└ AnimatedContent (mic ↔ send ↔ listening)
|
||
├ mic: FilledTonalButton(Mic icon, 40dp, circle)
|
||
├ send: FilledTonalButton(Send icon, 40dp, circle, blue)
|
||
└ listening: FilledTonalButton(Stop icon, 40dp, circle, red)
|
||
</pre>
|
||
|
||
<hr style="border-color:rgba(255,255,255,0.06); margin: 28px 0 16px;" />
|
||
|
||
<h3 style="color:#ddd; font-weight:500; font-size:14px; margin-bottom:12px;">🎯 What each dropdown option does</h3>
|
||
|
||
<table style="font-size:13px; color:#ccc; border-collapse: collapse; max-width:600px;">
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);">
|
||
<th style="padding:8px 12px; text-align:left; color:#888;">Option</th>
|
||
<th style="padding:8px 12px; text-align:left; color:#888;">Launcher</th>
|
||
<th style="padding:8px 12px; text-align:left; color:#888;">MIME</th>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);">
|
||
<td style="padding:8px 12px;">📷 Camera</td>
|
||
<td style="padding:8px 12px;"><code>TakePicture</code> → temp FileProvider URI</td>
|
||
<td style="padding:8px 12px;">image/jpeg</td>
|
||
</tr>
|
||
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);">
|
||
<td style="padding:8px 12px;">🖼️ Image</td>
|
||
<td style="padding:8px 12px;"><code>GetContent("image/*")</code></td>
|
||
<td style="padding:8px 12px;">image/* (gallery picker)</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:8px 12px;">📄 File</td>
|
||
<td style="padding:8px 12px;"><code>GetContent("*/*")</code></td>
|
||
<td style="padding:8px 12px;">*/* (system file picker)</td>
|
||
</tr>
|
||
</table>
|
||
|
||
</body>
|
||
</html>
|