Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/engineering/otter_archive_and_conversation_images.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,71 @@ handoff increments `event_handoff_failures`, leaves the meeting pending, and
makes the run partial while retaining the successful preservation receipt. A
retry reuses the event's durable instance when one already exists. A queued or
reused workflow does not prove its later mail, deck or paper effects succeeded.

## Mobile/PWA image picker (local acceptance, 14 September 2026)

The composer has an **Attach image** button beside the draft. Choose one or more
photos/files, wait for previews, enter an instruction, then send. **More actions →
Paste image** attempts the browser clipboard API; ordinary image paste into the
conversation and the general **Upload File** control use the same image pipeline.
Remove an image and choose another to replace it. Removal cancels an in-progress
browser upload and excludes any late result from the request; an already stored
source is not deleted. Failed uploads remain visible, block sending, and offer
retry/removal. Closing the picker leaves the existing draft intact.

The picker advertises PNG, JPEG and WebP. HEIC, animated and other unsupported
images need conversion to a supported still format; the backend checks actual
bytes, dimensions and size. Recognised image filenames with an empty browser MIME
type still use that validator and remain image attachments rather than generic
workflow file inputs. Clipboard denial, cancellation, missing APIs and an empty
clipboard provide the picker as a fallback. No clipboard permission is requested
until **Paste image** is pressed. There is no offline upload queue: retry when
connectivity returns. Unsent image selections are not restored after a page reload.

Acceptance for PR #658, product revision
`87f70a14f973ccedb1500b395ea554fb523dddb2`:

- 34 targeted frontend tests passed across attachment request binding, image
validation/cancellation, picker/clipboard controls, upload recovery and compact
composer behaviour. All 16 `tests/backend/test_conversation_images.py` tests
passed, covering authenticated upload/original routes, invalid bytes, actor
isolation and provider-byte transport without issuing model requests.
- `tests/browser/imagePickerAuthenticated.cjs` passed on the operator-prepared
disposable fixture at `http://127.0.0.1:5085`, using visible Browser test login
as synthetic `#V#blocked_picker658_alice` and a fresh conversation per viewport.
Chromium 153.0.8010.12 passed at 360, 412 and 1440 pixels: native chooser event,
actual uploads, SHA-256 and original-byte read-back, decoded previews,
removal/replacement, preserved caption, cancellation, 44-pixel attachment
control and no composer overflow. A failed upload disabled Send and a real
retry succeeded. The actual request builder submitted only the replacement ID
to the newly created conversation, and no image IDs on the next text request.
- The same browser loaded the served standalone PWA manifest and exercised the
composer with the actual `/von/service-worker.js` controlling `/von/`. This is
local PWA-shell acceptance, not physical-device installation or offline upload.
- `tests/browser/imagePicker.cjs` passed at three widths;
`tests/browser/composerControls.cjs` passed all six desktop/touch/short profiles.
`tests/browser/mobileScreenshotPaste.cjs` passed at three widths with real
desktop clipboard grants/denial, preserved captions, ordinary multiline text
paste and stubbed upload endpoints. Authenticated and component evidence is
retained under the task worktree's `.run/image-picker-authenticated/` and
`.run/image-picker-current/` respectively.

The authenticated fixture deliberately disables generation. Browser submissions
were intercepted at `/von/generate`; this does not establish a model answer or
persisted conversation history. It verifies actual upload/storage/read-back and
frontend request association, complemented by the targeted backend tests.

The operator's Android 17 emulator evidence used Chrome 145 and Gboard 17.2,
with native Copy/Paste image taps, a real clipboard permission prompt and native
Allow. The unchanged PR658 helper returned a PNG that decoded to 24×16 and
preserved captions. Source and clipboard PNG hashes differ because Chromium
re-encodes clipboard images. The evidence archive is file copy
`#V#computer_file_copy_7c09b5898bec45148912a9c8bb53a996`, SHA-256
`4b4a8838656df8a3a5d8b1b360b7e1c5370b1e848120f98f6706a02531e46af2`.
This establishes the helper's native capability, not physical Pixel, Chrome 152,
iOS, original-byte clipboard fidelity or a fix for Gboard keyboard-tile refusal.
The earlier fixture-repair archive is
`#V#computer_file_copy_9e29aa91aa07485a9054d95ddb85de40`, SHA-256
`35ed4ddb42f3a96469df16d465a9f230770904c5e402c68d9f34fde3a6ba6cd0`;
it establishes setup recovery only. Both supplied archive checksums were verified.
No public deployment was requested by this task.
6 changes: 3 additions & 3 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_
import { createVoiceConversation } from './voiceConversation.js';
import { getClientContext } from './clientContext.js';
import { createDictationController } from './dictation.js';
import { initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js";
import { isConversationImageFile, initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js";
// Chat Tab Module
import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js';
import {
Expand Down Expand Up @@ -21573,7 +21573,7 @@ function refreshConversationImages() {

function syncConversationAttachmentWorkflowBinding(sessionId) {
const key = getPendingFileCopySessionKey(sessionId);
const last = imageItems(sessionId).filter(item => item.descriptor && !item.type.startsWith('image/')).at(-1);
const last = imageItems(sessionId).filter(item => item.descriptor && !isConversationImageFile(item)).at(-1);
if (last) {
pendingFileCopyConceptIdsBySession.set(key, last.descriptor.concept_id);
pendingFileCopyDisplayNamesBySession.set(key, last.name);
Expand Down Expand Up @@ -21939,7 +21939,7 @@ async function performUploadFilesToVon(list, uploadState) {
});
if (uploaded) {
successCount += 1;
if (!file.type.startsWith('image/')) {
if (!isConversationImageFile(file)) {
const item = imageItems(uploadTargetSessionId).findLast(item => item.name === file.name && item.descriptor);
if (item) rememberPendingUploadedFileCopyConceptId(item.descriptor.concept_id, uploadTargetSessionId, file.name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test('failed preparation remains visible and prevents text-only submission until
expect(panel.querySelector('[role=alert]').textContent).toMatch(/still PNG/);
expect(imagesBlocked('failed')).toBe(true);
expect(() => takeImages('failed')).toThrow(/preparation failed/);
panel.querySelector('button[aria-label="Remove bad.svg"]').click();
panel.querySelector('button[aria-label^=Remove]').click();
expect(imagesBlocked('failed')).toBe(false);
});

Expand All @@ -51,3 +51,38 @@ test('fallback Markdown resolves exact archive citations without allowing arbitr
expect(parent.querySelector('a').getAttribute('href')).toBe(`/von/api/otter-archive/artifacts/${artifact}`);
expect(parent.querySelectorAll('a')).toHaveLength(1);
});

test('removal cancels in-flight upload and ignores a late response', async () => {
let finish;
global.fetch = jest.fn(() => new Promise(resolve => { finish = resolve; }));
const panel = document.createElement('div');
const render = () => renderImageComposer(panel, 'cancel', render);
const upload = uploadConversationImage(new File(['bytes'], 'cancel.png', {type:'image/png'}), 'cancel', {}, render);
panel.querySelector('button').click();
expect(fetch.mock.calls[0][1].signal.aborted).toBe(true);
finish({ok:true, json:async () => ({image_attachment:{concept_id:'#V#late'}})});
expect(await upload).toBe(false);
expect(takeImages('cancel')).toEqual([]);
});

test('network failure can retry without duplicating or crossing conversation bindings', async () => {
global.fetch = jest.fn().mockRejectedValueOnce(new Error('Network unavailable')).mockResolvedValueOnce({ok:true, json:async () => ({image_attachment:{concept_id:'#V#retried'}})});
const panel = document.createElement('div');
const render = () => renderImageComposer(panel, 'retry', render);
await uploadConversationImage(new File(['bytes'], 'retry.png', {type:'image/png'}), 'retry', {}, render);
expect(imagesBlocked('retry')).toBe(true);
await imageItems('retry')[0].retry();
expect(imageItems('retry')).toHaveLength(1);
expect(takeImages('elsewhere')).toEqual([]);
expect(takeImages('retry')).toEqual(['#V#retried']);
});

test.each([
['large.png', 'image/png', 8 * 1024 * 1024 + 1, /8 MiB/],
['phone.heic', 'image/heic', 1, /Convert HEIC/]
])('invalid selection %s reports actionable error before upload', async (name, type, size, message) => {
global.fetch = jest.fn();
await uploadConversationImage(new File([new Uint8Array(size)], name, {type}), name, {}, () => {});
expect(fetch).not.toHaveBeenCalled();
expect(imageItems(name)[0].error).toMatch(message);
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function renderImageComposer(parent, sessionId, changed) {
parent.replaceChildren();
for (const item of imageItems(sessionId)) {
const card = document.createElement('div');
card.style.cssText = 'display:inline-flex;flex-direction:column;gap:4px;margin:6px;max-width:180px;min-width:0;overflow-wrap:anywhere';
card.className = 'conversation-image-draft';
if (item.descriptor) {
renderImageAttachments(card, [item.descriptor]);
card.querySelectorAll('img').forEach(img => { img.style.maxHeight = '100px'; });
Expand Down Expand Up @@ -96,13 +96,17 @@ export function renderImageComposer(parent, sessionId, changed) {
const remove = document.createElement('button');
remove.type = 'button'; remove.textContent = 'Remove'; remove.setAttribute('aria-label', `Remove ${item.name}`);
remove.onclick = () => {
item.controller?.abort();
pending.set(sessionId, imageItems(sessionId).filter(x => x !== item));
releasePreview(item);
changed();
};
card.appendChild(remove); parent.appendChild(card);
}
}
export function isConversationImageFile(file) {
return file.type?.startsWith('image/') || /\.(png|jpe?g|webp|heic|heif|gif|bmp|tiff?|svg)$/i.test(file.name || '');
}
export async function uploadConversationImage(file, sessionId, headers, changed) {
const item = {
name: file.name, type: file.type, size: file.size,
Expand All @@ -119,15 +123,23 @@ function releasePreview(item) {
async function uploadItem(item, file, sessionId, headers, changed) {
if (item.uploading || !imageItems(sessionId).includes(item)) return false;
item.uploading = true;
item.controller = new AbortController();
item.error = null;
changed();
try {
if (imageItems(sessionId).length > 8) throw new Error('At most eight attachments per message. Remove an attachment before sending.');
const isImage = isConversationImageFile(file);
if (isImage) {
if (!file.size || file.size > 8 * 1024 * 1024) throw new Error('Images must be nonempty and at most 8 MiB.');
if (file.type && !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
throw new Error('Use a still PNG, JPEG or WebP image. Convert HEIC or other formats before attaching.');
}
}
const form = new FormData(); form.append('file', file, file.name || 'image.png');
form.append('conversation_attachment', '1');
const endpoint = file.type.startsWith('image/') ? '/von/api/images/upload' : '/von/api/files/upload';
const response = await fetch(endpoint, {method:'POST', headers, body:form});
const result = await response.json();
const endpoint = isImage ? '/von/api/images/upload' : '/von/api/files/upload';
const response = await fetch(endpoint, {method:'POST', headers, body:form, signal:item.controller.signal});
const result = await response.json().catch(() => ({}));
const descriptor = result.image_attachment || (result.uploaded?.concept_id ? {...result.uploaded, filename: file.name, content_type: file.type || 'application/octet-stream', size_bytes: file.size} : null);
if (!response.ok || !descriptor) throw new Error(result.message || result.error || 'Attachment upload failed');
// Removing an in-flight upload must not restore it when the response arrives.
Expand Down
15 changes: 14 additions & 1 deletion src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -11884,7 +11884,7 @@ body[data-active-tab="settingsTab"] .tab-content-area {
}

.chat-composer-primary-actions {
width: 96px;
width: 148px;
}

.chat-composer-primary-actions>button:not(.composer-icon-button) {
Expand Down Expand Up @@ -18835,3 +18835,16 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu
min-height: 44px;
}
}

/* Image drafts remain within the compact mobile/PWA composer. */
#conversationImageComposer { flex-basis: 100%; min-width: 0; width: 100%; }
#conversationImageComposer:empty { display: none; }
.conversation-image-draft { display: inline-flex; vertical-align: top; flex-direction: column; gap: 4px; margin: 6px; max-width: min(180px, calc(100% - 12px)); overflow-wrap: anywhere; }
.conversation-image-draft button { min-height: 44px; }
.conversation-image-draft .conversation-image-gallery img { max-height: 140px !important; }
@media (max-width: 800px), (max-width: 1024px) and (pointer: coarse) {
.chat-composer { grid-template-columns: 44px 44px minmax(0, 1fr) auto; }
.chat-composer #attachImageButton { grid-column: 2; grid-row: 1; width: 44px; min-width: 44px; }
.chat-composer > .prompt-input-wrapper, .chat-composer > #promptInput { grid-column: 3; }
.chat-composer #sendButton { grid-column: 4; }
}
8 changes: 5 additions & 3 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ <h3 id="conversationReferenceInspectorTitle" class="chat-reference-inspector-tit
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5m-7 7 7-7 7 7" /></svg>
<span class="button-label sr-only">Send Prompt</span>
</button>
<button id="attachImageButton" class="btn btn-secondary composer-icon-button" type="button" title="Attach image" aria-label="Attach image" aria-describedby="imageAttachmentHelp">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1"/><path d="m3 17 6-6 4 4 3-3 5 5"/></svg>
</button>
<input id="attachImageInput" class="visually-hidden" type="file" tabindex="-1" accept="image/png,image/jpeg,image/webp" multiple aria-label="Choose images" />
<button id="dictateButton" class="btn btn-secondary composer-icon-button" type="button" aria-describedby="microphoneHelp" title="Click to dictate into your draft. Shift-click or long-press to start a voice conversation: speech is sent automatically and Von replies aloud.">
<svg class="microphone-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8" /></svg>
<svg class="dictation-stop-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
Expand All @@ -256,10 +260,8 @@ <h3 id="conversationReferenceInspectorTitle" class="chat-reference-inspector-tit
title="End this conversation (resets Von's context while keeping the conversation view)">End
conversation</button>

<p id="imageAttachmentHelp">If your keyboard cannot paste a screenshot, use Paste image and allow clipboard access, or choose Attach image. Your caption stays in the draft.</p>
<p id="imageAttachmentHelp">If your keyboard cannot paste a screenshot, use Paste image and allow clipboard access, or choose Attach image. Your caption stays in the draft. Attach up to eight still PNG, JPEG or WebP images, each up to 8 MiB and 25 million pixels. Review previews before sending. Remove an image to replace it.</p>
<button id="pasteImageButton" class="btn btn-secondary" type="button" aria-describedby="imageAttachmentHelp">Paste image</button>
<button id="attachImageButton" class="btn btn-secondary" type="button" aria-describedby="imageAttachmentHelp">Attach image</button>
<input id="attachImageInput" class="visually-hidden" type="file" tabindex="-1" accept="image/png,image/jpeg,image/webp" multiple aria-label="Choose images" />
<button id="uploadFileButton" class="btn btn-secondary" type="button"
title="Upload a file, drag and drop onto the conversation, or paste an image">Upload File</button>
<input id="uploadFileInput" class="visually-hidden" type="file" multiple aria-label="Upload file" />
Expand Down
31 changes: 31 additions & 0 deletions tests/backend/test_conversation_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,34 @@ def test_archive_resource_citations_resolve_only_to_authenticated_source_route()
links = BeautifulSoup(html, "html.parser").find_all("a")
assert links[0]["href"] == "/von/api/otter-archive/artifacts/" + artifact
assert not links[1].has_attr("href")


def test_picker_upload_validates_bytes_and_returns_request_descriptor(monkeypatch):
from flask import Blueprint, Flask
from src.backend.server.routes.conversation_image_routes import register_image_routes

stored = []

def store(**kwargs):
info = images.inspect_image(kwargs['data'])
stored.append(kwargs)
return {**info, 'concept_id': '#V#phone_image', 'filename': kwargs['filename']}

monkeypatch.setattr(images, 'store_image', store)
app = Flask(__name__)
bp = Blueprint('picker_test', __name__)
register_image_routes(bp)
app.register_blueprint(bp, url_prefix='/von')
client = app.test_client()
assert client.post('/von/api/images/upload').status_code == 401
with override_current_actor('#V#owner', None):
assert client.post('/von/api/images/upload').status_code == 400
invalid = client.post('/von/api/images/upload', data={'file': (io.BytesIO(b'bad'), 'phone.png')})
assert invalid.status_code == 400
assert invalid.json['error'] == 'invalid_image'
response = client.post('/von/api/images/upload', data={'file': (io.BytesIO(png()), 'phone.png')})
assert response.status_code == 201
assert response.json['image_attachment']['concept_id'] == '#V#phone_image'
assert response.json['image_attachment']['content_type'] == 'image/png'
assert len(stored) == 1
assert stored[0]['user_concept_id'] == '#V#owner'
2 changes: 2 additions & 0 deletions tests/browser/composerControls.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ async function bounds(page, selector) {
await page.keyboard.press('Tab');
await expect(send).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Attach image', exact: true })).toBeFocused();
await page.keyboard.press('Tab');
if (profile.desktop) {
await expect(mic).toBeFocused();
await page.keyboard.press('Tab');
Expand Down
Loading
Loading