Skip to content

Display pictures in senses#2412

Open
rmunn wants to merge 13 commits into
developfrom
feat/display-pictures-in-senses
Open

Display pictures in senses#2412
rmunn wants to merge 13 commits into
developfrom
feat/display-pictures-in-senses

Conversation

@rmunn

@rmunn rmunn commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

UI mostly coded by Claude; I just gave it design pointers. Please double-check that there aren't any code-style problems that I missed. (For example, did it use the correct carousel component? I think it did, but I'm not nearly as familiar with shadcn as @myieye is).

Tested locally by doing Send/Receive and/or Sync in both directions. The "+ Picture" button successfully uploads a picture and then the sync can send it to FieldWorks Classic. Currently limited to JPG and PNG, because I haven't yet thought about issues like whether we want to convert files in other formats, and so on.

UI shows just one picture if it's the only one, but if there are two or more then it shows a carousel, with a 10-second pause before switching the pictures. Feel free to bikeshed the pause duration if it's too long or too short, or anything else about it for that matter.

Design decisions to discuss

  • Currently the UI only allows uploading one picture; the "+ Picture" button disappears after uploading the first picture. That's probably fine for 99.9% of use cases. Do we want to show the "+ Picture" button anyway, even though most people never want to upload more than one picture?
  • No way (yet) to delete or replace pictures. Should we add a "Delete Picture" button (trash-can icon)? Should there also be a "Replace Picture" button that asks to upload a new picture?
  • Do we want to offer basic editing (cropping, rotating by multiples of 90°, maybe flipping) after upload? Should it be client-side before the file is sent to the server, or should we upload the file, then replace it with the edited file later?
    • Should cropping be non-destructive, i.e. we preserve the original file and the (x,y,w,h) info about the crop? Should we also generate a copy of the cropped file to store in the .fwdata. or does .fwdata already have a concept of cropping that I missed? TODO: Check again.
  • Do we want to offer automatic resizing and/or reducing JPEG quality in order to hit file-size targets? Currently the user is asked to do this if the file is more than our 10 MB limit. Some users won't have the computer skills to resize an image; should we offer to do it for them?
    • As with cropping, should we preserve the original? It won't fit into Send/Receive, so maybe we shouldn't.
  • Should we expand the list of accepted MIME types? Currently it's just JPG and PNG; I'm sure we want to accept a few other formats (.webp, .tiff, .avif, .heif/.heic, .jxl, .gif); are there any others I didn't think of? If someone tries to upload a .bmp or other uncompressed format, should we tell them "compress this and reupload"? Should we try to compress it ourselves? (Lossy? Lossless? Maybe let the user make that decision).
  • Allow capturing images with a camera? (Useful for Android build). Bump that to a later PR?

Design decisions we made

  • The "+ Picture" button should still appear even when a picture is there.
  • We want a "download/save as" button
  • We want a "delete" button, and also "replace picture"
  • No cropping yet
  • Capturing images with a camera is out-of-scope for this PR; bump to later one

Screenshots

One picture:

image

No pictures:

image

Two pictures (carousel mode):

carousel-centered-1 carousel-centered-2

rmunn and others added 10 commits July 1, 2026 13:48
Display a sense's pictures in the FwLite viewer, below Semantic Domains.

- Register a new `pictures` sense field (entity-config + FW Lite view) so it
  shows for every sense.
- PictureImage loads each picture via MiniLcmJsInvokable.GetFileStream
  (MediaUri -> stream -> blob URL), shows its caption (best analysis
  alternative), and handles not-found/offline/error states. Object URLs are
  revoked on teardown. No try/catch around async, per viewer conventions.
- PictureCarousel wraps the images in an embla carousel (embla-carousel-svelte)
  that auto-advances every 10s when there is more than one picture, with
  prev/next + dot navigation. Single-picture senses just show the image.
- PicturesEditor shows the carousel when pictures exist, otherwise a disabled
  "+ Picture" button styled like "+ Component" (adding pictures is not yet
  possible: MiniLcmJsInvokable exposes no create-picture API to the frontend).
- Export IPicture from the dotnet-types barrel (was omitted when generated).
- Demo: give the first "nyumba" sense two pictures and serve them as inline
  SVG blobs from the demo getFileStream, so the carousel is demonstrable in the
  in-browser demo project.
- Add a viewer Playwright test covering the populated (image + caption) and
  empty (disabled add button) states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up after running svelte-check, eslint and the i18n extractor:

- PictureCarousel: use the `onemblaInit` event attribute (Svelte 5 forbids
  mixing the legacy `on:` directive with `on*` handlers, and the embla
  package types this attribute) and pass the required `plugins: []` to the
  action. Convert the dot-sync arrow to a function declaration (func-style).
- Extract the new picture UI strings into the locale catalogs and add
  translator-context comments in en.po per the i18n context guide.

svelte-check: 0 errors / 0 warnings. eslint: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running the new Playwright test against the real demo data surfaced two issues:

- The demo pictures were added to `_entries`, which is dead code; the served
  data is `entries`, whose first item is replaced by `allWsEntry`. Move the two
  demo pictures onto `allWsEntry`'s sense (the live "nyumba" entry) and revert
  the `_entries` change.
- PicturesEditor crashed ("Cannot read properties of undefined (reading
  'length')") on senses whose `pictures` is absent at runtime — the bulk demo
  data (and legacy data) omit the field even though the type marks it required.
  Default it to an empty array before use.
- Update the test's empty-state case to use a single-sense entry ("ambuka")
  since the picture-bearing entry now has only one sense.

Both Playwright tests pass; svelte-check and eslint are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It's time to implement picture manipulation in the frontend, at least
basic stuff (uploading pictures, etc). No editing planned yet.
Wire up the previously-disabled "+ Picture" button in the sense editor so
users can add a picture to a sense.

- PicturesEditor: clicking the button opens a file picker limited to JPG/PNG,
  uploads the chosen file via saveFile, then calls createPicture with the
  returned mediaUri. Upload results are handled by branching on the result enum
  (no try/catch), matching PictureImage.
- Too-large files: the client does not hardcode the size limit (it may change
  server-side); it handles the server's TooBig result and shows a helpful
  message — suggest lowering JPEG quality for a JPG, or reducing resolution for
  a PNG.
- MiniLcmApiNotifyWrapper: notify on Create/Update/Move/DeletePicture (this was
  missing), so the entry reloads and the new picture renders, like every other
  write op.
- Demo API: implement saveFile (stores the blob, enforces the same 10 MB server
  limit) plus create/update/deletePicture, so the flow round-trips offline.
- Playwright: the empty-state button is now enabled; add coverage for uploading
  a picture through it and seeing it render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loading a not-yet-cached media file could crash with
"SQLite Error 19: 'UNIQUE constraint failed: LocalResource.Id'".

LcmMediaService.GetFileStream checks GetLocalResource, and on a miss calls
ResourceService.DownloadResource, which inserts a LocalResource keyed by the
file id. When the UI requests the same file more than once before it is cached
(e.g. on a picture's first render), two calls both see no local resource and
both download and insert, so the second insert violates the primary key.

Coalesce concurrent downloads of the same file into one shared task, kept in a
ConcurrentDictionary keyed by file id: the first caller starts the download and
every concurrent caller awaits that same task, which is removed once it
completes. Two races are handled:

- GetOrAdd's factory overload can run more than once under contention (which
  would start the download twice), so the not-yet-started TaskCompletionSource
  is added via the atomic value overload — only the caller whose task is stored
  starts the download.
- A caller can miss in GetLocalResource just before another caller commits the
  download. The shared task re-checks GetLocalResource before downloading, and
  the entry is removed only after the download commits, so a later fresh task
  finds the committed resource instead of inserting a duplicate key.

Different files still download in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetFileStream threw when a download failed (e.g. the media server/gateway
returns 504 Gateway Timeout, or the file is missing on the server). The
exception propagated all the way to the JS caller, so instead of the picture
UI showing an error state, an unhandled exception surfaced after a long wait.

Callers like PictureImage are written to expect failures via the ReadFileResult
enum, not exceptions. Catch download failures, log them, and return
ReadFileResult.Error with the message so the UI can show a graceful error
(and a later navigation can retry) rather than crashing.

Note: this does not make an unreachable/slow media server succeed — it only
makes the client fail gracefully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cold request to the proxied media endpoint (lexbox -> FwHeadless) can exceed
the proxy's timeout on the first touch — e.g. before the backend's code paths
are JIT-warmed — and come back as 504 (or 502/503) even though the backend then
warms up and serves the file in milliseconds. Previously the concurrent-download
bug masked this: several requests per file meant a later, warm one succeeded.
Now that downloads are coalesced into one, a single cold 504 was terminal.

Retry transient failures (408/502/503/504) up to 3 times with a short delay.
A failed fetch never reaches AddLocalResource, so this cannot reintroduce the
duplicate-key insert; non-transient failures still fail immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PictureImage: left-justify the image (and caption) instead of centering it.
- PicturesEditor: size the "+ Picture" button to its (translatable) label and
  right-align it, matching the "+ Component" button, rather than stretching to
  the full grid-column width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related label Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds picture CRUD support end-to-end: backend write API and JS invokable methods, updated generated TypeScript contracts, new Svelte carousel/image/editor components wired into the sense editor and view config, localized strings across eight locales, demo data/API support, and Playwright tests. Separately, LcmMediaService gains download coalescing and retry logic for transient failures.

Changes

Picture CRUD feature

Layer / File(s) Summary
Backend picture write API and JS bridge
backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs, backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
Wrapper implements picture create/update/move/delete with change tracking and batched notifications; JS invokable exposes CreatePicture/UpdatePicture/DeletePicture and triggers OnDataChanged.
Generated TypeScript contracts
frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts, .../MiniLcm/Models/IPicture.ts, frontend/viewer/src/lib/dotnet-types/index.ts
Adds createPicture/updatePicture/deletePicture signatures, removes deletedAt from IPicture, and re-exports the IPicture module.
Picture carousel, image loader, editor
frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte, PictureImage.svelte, PicturesEditor.svelte
New components implement an autoplaying Embla carousel, an async image loader with loading/error states, and an upload/editor flow that calls api.saveFile then api.createPicture.
Sense editor and view wiring
frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte, frontend/viewer/src/lib/views/entity-config.ts, frontend/viewer/src/lib/views/view-data.ts
Adds a pictures field to the sense editor, entity config, and FW_LITE_VIEW configuration.
Localized strings
frontend/viewer/src/locales/{en,es,fr,id,ko,ms,sw,vi}.po
Adds carousel navigation, empty-state, error, and upload-guidance strings for pictures across all locales.
Demo data and in-memory API
frontend/viewer/src/project/demo/demo-entry-data.ts, frontend/viewer/src/project/demo/in-memory-demo-api.ts
Adds demo picture media/SVGs and populates demo pictures; implements in-memory createPicture/updatePicture/deletePicture and getFileStream/saveFile with a size limit.
End-to-end tests
frontend/viewer/tests/sense-pictures.test.ts
Playwright tests cover rendering existing pictures, the empty state, and the upload flow.

Media download coalescing and retry

Layer / File(s) Summary
Download coalescing and retry logic
backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
Adds a shared in-flight task map to coalesce concurrent downloads per fileId and retry-with-delay logic for transient HTTP failures in RequestMediaFile.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

Suggested labels: 📦 Lexbox

Suggested reviewers: hahn-kev, myieye

Poem

A hop, a click, a picture's birth,
Carousels spin with quiet mirth.
Downloads once tangled, now unite,
One task per file, coalesced just right.
With captions, labels, and locales anew—
This bunny's burrow ships pictures too! 🐇🖼️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main change: adding picture display support in senses.
Description check ✅ Passed The description is clearly about the picture-support changes and related UI, API, and sync behavior.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/display-pictures-in-senses

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
frontend/viewer/tests/sense-pictures.test.ts (1)

26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate fragile selector across all three tests.

[style*="grid-area: pictures"] is repeated verbatim in each test, coupling all of them to an inline-style implementation detail. Consider adding a picturesField helper (e.g., on BrowsePage/EntryViewComponent) so the selector lives in one place.

♻️ Proposed helper extraction
// browse-page.ts (or EntryViewComponent)
+  picturesField(page: Page) {
+    return page.locator('[style*="grid-area: pictures"]').first();
+  }
-    const picturesField = page.locator('[style*="grid-area: pictures"]').first();
+    const picturesField = browsePage.entryView.picturesField(page);

Also applies to: 46-46, 62-62

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/viewer/tests/sense-pictures.test.ts` at line 26, The three tests are
repeating the same fragile pictures selector, which is tightly coupled to an
inline style detail. Add a shared helper such as picturesField on BrowsePage or
EntryViewComponent and use it in each test instead of inlining
page.locator('[style*="grid-area: pictures"]'). Keep the selector logic in one
place so the tests reference the helper rather than duplicating the locator.
frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte (1)

43-80: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

No client-side file-type validation before upload.

uploadPicture relies entirely on the server's NotSupported result to reject non-image files; the accept attribute on the file input is only a UI hint and can be bypassed (e.g., "All Files" in the OS picker). This is already handled by the server, so it's a minor defense-in-depth gap rather than a functional bug.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte`
around lines 43 - 80, Add a client-side image-type check in uploadPicture before
calling api.saveFile, since the file input accept hint can be bypassed and the
current flow only relies on the server’s UploadFileResult.NotSupported response.
Use the existing uploadPicture function in PicturesEditor.svelte to reject
non-image File types early with the same user-facing notification, while still
keeping the server-side validation as the source of truth.
frontend/viewer/src/locales/en.po (1)

1569-1579: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale translator comment: button is no longer disabled.

The comment describes the "Picture" label as being on a "currently-disabled '+ Picture' add button," but per the PicturesEditor.svelte implementation, the button is wired to onclick={selectFile} and actively triggers file upload/api.createPicture. This context comment appears to predate the upload-wiring commit and should be updated to avoid confusing translators about the button's actual behavior.

✏️ Suggested comment fix
-#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on a currently-disabled "+ Picture" add button in the sense editor
+#. Two uses: (1) alt text for a picture image when no caption is available; (2) label on the "+ Picture" add button in the sense editor
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/viewer/src/locales/en.po` around lines 1569 - 1579, The translator
comment for the “Picture” msgid is stale and incorrectly says the add button is
currently disabled. Update the comment next to the Picture/Pictures entries in
the locale file so it matches the current behavior in PictureImage.svelte and
PicturesEditor.svelte, describing the label as the add/upload picture button
that opens file selection and triggers picture creation rather than a disabled
control.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs`:
- Around line 150-161: The JS bridge is missing the picture reordering
operation, so add MovePicture exposure alongside the existing picture
create/update/delete paths. Update the MiniLcmJsInvokable and
IMiniLcmJsInvokable contract to include a MovePicture method that forwards to
the underlying write API, matching the existing patterns used for picture
actions. Ensure the new bridge method uses the same identifiers and parameters
as IMiniLcmWriteApi.MovePicture so the viewer can invoke reordering directly.

In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte`:
- Around line 92-113: The PicturesEditor.svelte upload control is only rendered
in the empty-state branch, so once the carousel shows existing pictures the add
button and file input become unreachable. Update the conditional rendering
around PictureCarousel and the “Picture” Button/file input so the add action
remains available whenever !readonly, even when pictures.length > 0; use the
existing PictureCarousel, selectFile, fileInputElement, and uploading symbols to
place the button alongside the carousel.

In `@frontend/viewer/src/project/demo/in-memory-demo-api.ts`:
- Around line 561-601: The demo upload flow is losing the original filename
because saveFile only stores the Blob in `#uploadedFiles` and getFileStream later
reconstructs fileName from mediaUri. Update the in-memory demo API to persist
metadata.filename alongside the uploaded Blob, and have getFileStream return
that stored name for uploaded files instead of deriving it from demo-upload ids.
Keep the existing demo-picture fallback behavior unchanged in getFileStream and
preserve the current upload/saveFile contract.

---

Nitpick comments:
In `@frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte`:
- Around line 43-80: Add a client-side image-type check in uploadPicture before
calling api.saveFile, since the file input accept hint can be bypassed and the
current flow only relies on the server’s UploadFileResult.NotSupported response.
Use the existing uploadPicture function in PicturesEditor.svelte to reject
non-image File types early with the same user-facing notification, while still
keeping the server-side validation as the source of truth.

In `@frontend/viewer/src/locales/en.po`:
- Around line 1569-1579: The translator comment for the “Picture” msgid is stale
and incorrectly says the add button is currently disabled. Update the comment
next to the Picture/Pictures entries in the locale file so it matches the
current behavior in PictureImage.svelte and PicturesEditor.svelte, describing
the label as the add/upload picture button that opens file selection and
triggers picture creation rather than a disabled control.

In `@frontend/viewer/tests/sense-pictures.test.ts`:
- Line 26: The three tests are repeating the same fragile pictures selector,
which is tightly coupled to an inline style detail. Add a shared helper such as
picturesField on BrowsePage or EntryViewComponent and use it in each test
instead of inlining page.locator('[style*="grid-area: pictures"]'). Keep the
selector logic in one place so the tests reference the helper rather than
duplicating the locator.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2e80063c-04e1-4921-8d8e-4e30ad159fca

📥 Commits

Reviewing files that changed from the base of the PR and between a9cf4ca and 056d35b.

📒 Files selected for processing (23)
  • backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs
  • backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
  • backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs
  • frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts
  • frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts
  • frontend/viewer/src/lib/dotnet-types/index.ts
  • frontend/viewer/src/lib/entry-editor/field-editors/PictureCarousel.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte
  • frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte
  • frontend/viewer/src/lib/views/entity-config.ts
  • frontend/viewer/src/lib/views/view-data.ts
  • frontend/viewer/src/locales/en.po
  • frontend/viewer/src/locales/es.po
  • frontend/viewer/src/locales/fr.po
  • frontend/viewer/src/locales/id.po
  • frontend/viewer/src/locales/ko.po
  • frontend/viewer/src/locales/ms.po
  • frontend/viewer/src/locales/sw.po
  • frontend/viewer/src/locales/vi.po
  • frontend/viewer/src/project/demo/demo-entry-data.ts
  • frontend/viewer/src/project/demo/in-memory-demo-api.ts
  • frontend/viewer/tests/sense-pictures.test.ts
💤 Files with no reviewable changes (1)
  • frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPicture.ts

Comment thread backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs
Comment on lines +561 to +601
// Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the
// mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend.
#uploadedFiles = new Map<string, Blob>();

getFileStream(mediaUri: string): Promise<IReadFileResponseJs> {
const uploaded = this.#uploadedFiles.get(mediaUri);
if (uploaded) {
return Promise.resolve({
result: ReadFileResult.Success,
fileName: mediaUri.split('/').pop() ?? 'demo-upload',
stream: {
stream: () => Promise.resolve(uploaded.stream()),
arrayBuffer: () => uploaded.arrayBuffer(),
},
});
}
const svg = demoPictureSvgs[mediaUri];
if (!svg) return Promise.resolve({result: ReadFileResult.NotFound});
const blob = new Blob([svg], {type: 'image/svg+xml'});
return Promise.resolve({
result: ReadFileResult.Success,
fileName: 'demo-picture.svg',
stream: {
stream: () => Promise.resolve(blob.stream()),
arrayBuffer: () => blob.arrayBuffer(),
},
});
}

saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise<IUploadFileResponse> {
return Promise.resolve({result: UploadFileResult.NotSupported});
saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise<IUploadFileResponse> {
const blob = streamReference instanceof Blob
? streamReference
: new Blob([streamReference as BlobPart], {type: metadata.mimeType});
// The demo stands in for the server, so it enforces the same 10 MB limit the real
// backend does — exercising the client's TooBig handling without a backend.
if (blob.size > DEMO_FILE_SIZE_LIMIT) {
return Promise.resolve({result: UploadFileResult.TooBig});
}
const mediaUri = `demo-upload/${randomId()}`;
this.#uploadedFiles.set(mediaUri, blob);
return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Uploaded file's original name is discarded.

saveFile receives metadata.filename but never stores it; getFileStream later derives fileName from the synthesized mediaUri instead of the original upload name. Real backend behavior presumably preserves the uploaded filename.

🐛 Proposed fix to preserve original filename
-  `#uploadedFiles` = new Map<string, Blob>();
+  `#uploadedFiles` = new Map<string, {blob: Blob; fileName: string}>();

   getFileStream(mediaUri: string): Promise<IReadFileResponseJs> {
     const uploaded = this.#uploadedFiles.get(mediaUri);
     if (uploaded) {
       return Promise.resolve({
         result: ReadFileResult.Success,
-        fileName: mediaUri.split('/').pop() ?? 'demo-upload',
+        fileName: uploaded.fileName,
         stream: {
-          stream: () => Promise.resolve(uploaded.stream()),
-          arrayBuffer: () => uploaded.arrayBuffer(),
+          stream: () => Promise.resolve(uploaded.blob.stream()),
+          arrayBuffer: () => uploaded.blob.arrayBuffer(),
         },
       });
     }
     ...
     const mediaUri = `demo-upload/${randomId()}`;
-    this.#uploadedFiles.set(mediaUri, blob);
+    this.#uploadedFiles.set(mediaUri, {blob, fileName: metadata.filename});
     return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the
// mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend.
#uploadedFiles = new Map<string, Blob>();
getFileStream(mediaUri: string): Promise<IReadFileResponseJs> {
const uploaded = this.#uploadedFiles.get(mediaUri);
if (uploaded) {
return Promise.resolve({
result: ReadFileResult.Success,
fileName: mediaUri.split('/').pop() ?? 'demo-upload',
stream: {
stream: () => Promise.resolve(uploaded.stream()),
arrayBuffer: () => uploaded.arrayBuffer(),
},
});
}
const svg = demoPictureSvgs[mediaUri];
if (!svg) return Promise.resolve({result: ReadFileResult.NotFound});
const blob = new Blob([svg], {type: 'image/svg+xml'});
return Promise.resolve({
result: ReadFileResult.Success,
fileName: 'demo-picture.svg',
stream: {
stream: () => Promise.resolve(blob.stream()),
arrayBuffer: () => blob.arrayBuffer(),
},
});
}
saveFile(_streamReference: Blob | ArrayBuffer | Uint8Array, _metadata: ILcmFileMetadata): Promise<IUploadFileResponse> {
return Promise.resolve({result: UploadFileResult.NotSupported});
saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise<IUploadFileResponse> {
const blob = streamReference instanceof Blob
? streamReference
: new Blob([streamReference as BlobPart], {type: metadata.mimeType});
// The demo stands in for the server, so it enforces the same 10 MB limit the real
// backend does — exercising the client's TooBig handling without a backend.
if (blob.size > DEMO_FILE_SIZE_LIMIT) {
return Promise.resolve({result: UploadFileResult.TooBig});
}
const mediaUri = `demo-upload/${randomId()}`;
this.#uploadedFiles.set(mediaUri, blob);
return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri});
// Files uploaded during the demo session (e.g. via the "+ Picture" button), keyed by the
// mediaUri handed back from saveFile. Lets the demo round-trip an upload without a backend.
`#uploadedFiles` = new Map<string, {blob: Blob; fileName: string}>();
getFileStream(mediaUri: string): Promise<IReadFileResponseJs> {
const uploaded = this.#uploadedFiles.get(mediaUri);
if (uploaded) {
return Promise.resolve({
result: ReadFileResult.Success,
fileName: uploaded.fileName,
stream: {
stream: () => Promise.resolve(uploaded.blob.stream()),
arrayBuffer: () => uploaded.blob.arrayBuffer(),
},
});
}
const svg = demoPictureSvgs[mediaUri];
if (!svg) return Promise.resolve({result: ReadFileResult.NotFound});
const blob = new Blob([svg], {type: 'image/svg+xml'});
return Promise.resolve({
result: ReadFileResult.Success,
fileName: 'demo-picture.svg',
stream: {
stream: () => Promise.resolve(blob.stream()),
arrayBuffer: () => blob.arrayBuffer(),
},
});
}
saveFile(streamReference: Blob | ArrayBuffer | Uint8Array, metadata: ILcmFileMetadata): Promise<IUploadFileResponse> {
const blob = streamReference instanceof Blob
? streamReference
: new Blob([streamReference as BlobPart], {type: metadata.mimeType});
// The demo stands in for the server, so it enforces the same 10 MB limit the real
// backend does — exercising the client's TooBig handling without a backend.
if (blob.size > DEMO_FILE_SIZE_LIMIT) {
return Promise.resolve({result: UploadFileResult.TooBig});
}
const mediaUri = `demo-upload/${randomId()}`;
this.#uploadedFiles.set(mediaUri, {blob, fileName: metadata.filename});
return Promise.resolve({result: UploadFileResult.SavedLocally, mediaUri});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/viewer/src/project/demo/in-memory-demo-api.ts` around lines 561 -
601, The demo upload flow is losing the original filename because saveFile only
stores the Blob in `#uploadedFiles` and getFileStream later reconstructs fileName
from mediaUri. Update the in-memory demo API to persist metadata.filename
alongside the uploaded Blob, and have getFileStream return that stored name for
uploaded files instead of deriving it from demo-upload ids. Keep the existing
demo-picture fallback behavior unchanged in getFileStream and preserve the
current upload/saveFile contract.

rmunn and others added 3 commits July 6, 2026 09:45
The picture carousel filled the full field width, so its centered prev/next/dot
controls floated far to the right of the left-justified picture. Shrink the
carousel to the width of its content (the picture) with w-fit, so it stays
left-justified and the centered controls land directly under the picture.
max-w-full keeps a very wide picture from overflowing the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picture carousel filled the full field width, so its centered
prev/next/dot controls floated far to the right of the left-justified picture.

When there are multiple pictures (the only time controls show), bound the
carousel to a fixed width and center each picture within it, so the centered
controls sit directly under the picture. Sizing the carousel to the picture
itself isn't possible here — embla lays its slides out in a flex row, so a
shrink-to-fit width would span the sum of all slides. A single picture is
unchanged (natural size, left-justified) since it has no controls to align.

A bounded box also has a definite width, so the loading placeholder fills it
instead of collapsing — the box stays put while the image loads (which can take
a second or two on slow mobile networks) rather than reflowing once it arrives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For the future, if we decide to allow rearranging the order of pictures
in the FW Lite UI.
@myieye

myieye commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Some thoughts:

  • I don't think we necessarily need a "Replace image" button. I don't think most apps do that.
  • I'm not convinced we should be using a carousel. It hides data. What's our motivation for doing that? Is it just that it ensures things stay clean if there's an absurd amount of images? I think we should just show them all.
  • I think a tidy way of showing multiple images is to force them all to the same height. Something like h-40 w-auto object-contain. Captions could sit as children of the same flex-box item and be capped with a max-width and maybe line-clamp too.

Things I'd do in a follow up PR:

  • Click/tap an image to view it full-screen-ish
  • Showing and editing captions
  • Reordering

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants