Files
deepseek-harness/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md
T
2026-08-12 14:47:20 +08:00

7.3 KiB
Raw Blame History

Agent Note: Whole-page image drop, projected intake limits, and thumbnail tiling

Status: implemented

English | 中文

Problem

The second alignment step for issue #2248, after the attachment display note (whose rail/toast/atoms decisions stand; this note supersedes its history-gallery geometry and the lightbox backdrop specifics). Remaining gaps against DeepSeek Chat: images could only be dropped on the composer card — a drop over the transcript navigated the browser away to the file; the lightbox close glyph was a bare × text character (buttons inherit no font family and the glyph's ink sits above the line box center, so it rendered visibly off-center) over a color-mix(label-primary 74%) backdrop that inverts to a bright white wash in dark mode; a message's images stacked vertically as up-to-240px blocks because the gallery container itself was pinned to 240px; and nothing client-side enforced or displayed the image limits — a user could stack 50 images and learn about maxImagesPerMessage only from a raw attachment-error (TOO_MANY_IMAGES) toast after submit, watching the rail empty and refill around the failure.

Decision

Whole-page drop. InputBar binds dragenter/dragover/dragleave/drop on the document (enter/leave depth counting, viewport-edge and dragend resets, Files-type gating so text drags keep their native textarea path) and renders the new DropOverlay atom in ui-attachment: a body-portaled, pointer-inert full-viewport layer (DeepSeek Chat's DragMask visuals — white/70% + 10px blur, dark rgba(39,39,48,0.7), illustration, title, limits line) whose disabled variant announces a locked/busy composer. Pointer-inertness is load-bearing: drag events keep targeting the page below, so the depth count never sees the overlay itself. Document-level listener state is safe because the composer-bar slot is kind: 'single'.

Lightbox. The close control is ui-primitives' IconCloseOutline16 (the Modal precedent — an SVG centered in its viewBox needs no font metrics). The backdrop is the shared dialog mask (--dsw-alias-bg-mask-1 + --dsw-mask-blur, black-based in both themes) painted on a separate sibling layer, because backdrop-filter on the container would blur the previewed image itself.

History thumbnails (DeepSeek Chat rules). A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by cover with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant image blocks merge into one gallery so they tile instead of each opening a one-image row.

Limits aligned and projected. Defaults are 20 images / 5 MiB per image / 100 MiB aggregate (attachment-local), with the HTTP carrier cap raised to one shared DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. The 5 MiB per-image default admits images across Anthropic routes that impose either a 5 MiB or 10 MiB maximum; deployments using only routes with the larger limit can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the imageLimits session projection — a constant-per-boot unit (apply returns the same state reference, so baselines alone carry it and no change frames exist) registered by apiproxy, not the attachment Service Definition: dsh-llm depends on dsh-attachment (ImageBlock → ImageAttachmentRef), so the seam package referencing dsh-session-projection (whose graph reaches dsh-llm through dsh-session) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The SessionProjectionMap merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports.

Intake pre-check and error copy. Both intake gestures converge on one intakeImages wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before addImages: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation.

Alternatives considered

Registering the projection unit in the attachment Service Definition's constructor. The natural seam owner, and the first implementation — rejected by the dependency graph (the cycle above) and by a test-harness interaction: the base constructor calling ctx.inject made directly-constructed stores in specs trigger the global invariant host, which then double-mounted an attachments service into the same root.

--dsw-alias-bg-mask-photo (0.88 black, theme-stable, unused) for the lightbox. The design system's photo-viewer token and dsweb's likely lightbox wash; the user chose consistency with the settings dialog mask (bg-mask-1 + blur) — both fix the dark-mode inversion.

Pre-checking inside apply.ts's addImages inject. The seam-purist placement, rejected for plumbing cost: the projection store has no non-React face exposed to the inject factory, while InputBar already consumes projections idiomatically and is the single caller of both gestures.

A host.describe field instead of a projection. Session-independent and cheaper, but delivered through an injected prop chain rather than useProjection, and the projection's key-absence semantics ("no attachment service composed → no pre-check") fall out for free.

Consequences

A drop anywhere on the window now lands in the rail, over-limit intake fails at the moment of the gesture with copy naming the limit, and history images tile like DeepSeek Chat's. The carrier's default request-body budget is ~5× larger and remains a per-request resident-memory bound (the bridge buffers bodies whole; recorded in the connection README's limitations). The fixture transport mirrors the projection with hardcoded default numbers — a deployment that overrides the limits diverges from fixture-mode copy, acceptable for a keyless demo lane. Gallery arrow navigation, lightbox zoom/download, and non-image file cards remain deferred (#2248).