Skip to content

Commit 2eb5064

Browse files
authored
polish: image-provider errors, design retry, slides animation paths, address-feedback skill (BuilderIO#658)
* chore(agents-index): register address-feedback skill in AGENTS.md Wires the new `.agents/skills/address-feedback/SKILL.md` into the skills table so the agent can find it. * fix(design/editor): preserve pending generation for retry when generation stops without output Two paths could clear a pending generation (`markGenerationStale` and the generation-complete fallback timer). Only the stale path remembered the prompt for retry. Now both share a `rememberPendingGenerationForRetry()` helper, so a generation that the agent ends without writing files still leaves the user a one-click retry banner with the original prompt / model / engine / effort intact. Also adds dedicated 'Use generate-design, do not call create-design' directives to the UI-started generation prompt so the agent doesn't accidentally fork into questions or variants on a UI-triggered run. * fix(images/managed-provider): surface actionable error detail when Builder image API fails Previously, when the Builder-managed image-generation route returned an error (401, 402, 403, 429, 503...), users saw the generic 'BUILDER_PRIVATE_KEY not configured' fallback message even though the real failure was unrelated (no credits, no space access, rate-limited, transient outage). The provider now: - Captures the API error detail from the JSON body when present. - Maps each known status to a specific user-facing message that describes both the likely cause and the recovery path (reconnect Builder space, switch to a space with credits, ask the space admin to enable access, retry shortly, or add a Gemini API key as the manual BYOK fallback). - Routes 401/402/403 through `FeatureNotConfiguredError` so the setup checklist points the user at the right credential, while other statuses surface as a regular runtime error with the same guidance text. * feat(slides/animations): persistent element targeting via elementPath Slide animations targeted children by a flat `elementIndex` into a heuristically-chosen 'content container' (last child with ≥2 children). That broke as soon as the slide HTML was restructured (intro added, wrapper inserted, list converted to grid, etc.) — each existing animation silently slid onto the wrong element. Each SlideAnimation now also stores `elementPath: number[]`, the child-index path from the outer `.fmd-slide` wrapper, so animations resolve to the same element across structural edits. `elementIndex` stays for back-compat with existing decks. Implementation: - New `templates/slides/app/lib/slide-animation-elements.ts` centralizes parsing + resolution: `parseSlideAnimationElements()`, `resolveSlideAnimationElement()`, `findLegacyAnimationContainer()`, `getSlideAnimationTargetKey()`, `getSlideAnimationTargetPreview()`. - AnimationsPanel and PresentationView both consume the shared module instead of duplicating the legacy 'last container' heuristic. - AnimationsPanel now keys 'used' / preview state by target key (path + index) so the picker reflects the new model. - Companion vitest spec covers parse / resolve / preview across legacy index-only animations and new path-based ones. * polish(clips/desktop/setup): longer permission labels + roomier permission button grid - 'Screen' / 'Speech' → 'Screen Recording' / 'Speech Recognition' so the macOS Privacy & Security pane the button opens matches what it's labeled. - Button grid now auto-fits 118px columns (instead of always two columns) so labels don't truncate mid-word at narrow widths; buttons also got a touch more vertical room (`min-height: 38px`, `line-height: 1.15`, `white-space: normal`). - Readiness summary row got matching breathing room: 32px min-height, rounded corners, 6px/10px padding when collapsed (open state keeps the existing square edge so it stitches to the body below). * polish: drop dead images fallback branch, recognize styled-shape slide targets, add image-provider tests Follow-ups to the earlier concurrent-agent commits on this branch. - templates/images/server/lib/generation.ts: the new `createBuilderImageGenerationFallbackError()` already covers every `shouldFallback` case where the error is a `BuilderImageGenerationError` (which is the only way we reach this block when the response wasn't OK). The duplicate `if (shouldFallback)` trailing branch that built a generic 'BUILDER_PRIVATE_KEY missing' message was unreachable — drop it. - templates/images/server/lib/generation.test.ts: vitest spec for the error-mapping logic (401/402/403/429/503 message mapping, JSON detail extraction, FeatureNotConfiguredError routing). - templates/slides/app/lib/slide-animation-elements.ts: empty styled shapes (e.g. a thin colored bar `<div style="width: 60px; height: 4px; background: #00E5FF;">`) are real animation targets, but `hasMeaningfulContent()` was filtering them out because they have no text and no img/svg/etc. Add a `hasVisualStyle()` check for inline style declarations that hint at a visible box (background/border/box-shadow/width/height/min-width/min-height). Spec covers a flex row containing a styled bar + a paragraph. * fix(design/editor): add rememberPendingGenerationForRetry to markGenerationStale useCallback deps The earlier commit on this branch (a2a29a1) introduced `rememberPendingGenerationForRetry` and called it from both `markGenerationStale` and `handleGenerationComplete`, but only added it to the second callback's dependency array. Add it to `markGenerationStale` too so an updated helper isn't captured stale. * test(slides/animations): integration spec for AnimationsPanel element list + elementPath Component-level spec for the AnimationsPanel + slide-animation-elements integration introduced earlier on this branch. Covers: - All animatable child elements appear in the panel's list (not just the legacy 'last container with ≥2 children'). - Auto-fill populates each animation with both `elementIndex` (for back-compat) and the new `elementPath` (for structural resilience), including nested flex children. * fix(slides): cleanup AnimationsPanel test DOM + gate slide↔URL sync on pending URL nav - AnimationsPanel.test.tsx: add `cleanup()` in `afterEach` so the two tests don't share rendered DOM between runs (vitest doesn't auto-cleanup on each test by default for @testing-library/react). - DeckEditor.tsx: introduce `pendingUrlSlideIdRef` to gate the active-slide → URL sync effect. When an external nav (the agent calling `navigate --slideIndex`) flips `activeSlideId`, the URL→slide effect already wrote the new `slide` param; the slide→URL effect would then race and rewrite it back to the previous slide for one render. The ref records 'this slide change came from a URL navigation' so the slide→URL effect skips its write until the ref clears (or the targeted slide disappears). * feat(clips): camera-composite lib — bubble overlay on top of display stream New utility that takes a display `MediaStream` and a camera `MediaStream` and produces a single composite `MediaStream` with the camera framed as a corner bubble on top of the screen-share. Configurable bubble size ratio + min/max pixel clamp, anchor (`bottom-left` / `bottom-right`), margin ratio, and frame rate. Returns a `cleanup()` handle so callers can release the canvas, the requestAnimationFrame loop, and the underlying video elements when the recording stops. * fix(core/sync): per-key invalidation for app-state one-shot commands Previously the server poll loop emitted a single `{ source: 'app-state', key: '*' }` event whenever ANY application_state row changed, and the client invalidated `navigate-command` / `show-questions` / `__set_url__` on every such event. Noisy app-state keys like `slide-fit-check` or per-tab UI state thus woke the navigation/ question readers on every poll cycle, breaking the optimistic "close after read" pattern for those one-shot commands. - Server (`packages/core/src/server/poll.ts`): fetch the changed rows, emit one event per changed (key, owner) pair. Owner is propagated so per-session targeting downstream still works. - Client (`packages/core/src/client/use-db-sync.ts`): `hasAppStateEvent(events, key)` checks whether the relevant batch contained that specific key (or a legacy '*' wildcard for back- compat), and only then invalidates the corresponding query. * feat(clips): record composited camera bubble + calendar end-time validator helper - templates/clips: screen+camera recordings now bake the camera bubble into the recorded video stream so it appears in the saved file. `recorder-engine.ts` calls the new `createCameraCompositeStream()` (already shipped earlier on this branch under `templates/clips/app/lib/camera-composite.ts`) when mode is screen+camera, and threads the user's `cameraBubbleSize` pre-record selection ("sm"/"md"/"lg") through to the `bubbleSizeRatio` parameter. Adds a desktop-app copy of the same lib at `templates/clips/desktop/src/lib/camera-composite.ts` so the Electron recorder can use the same compositor without cross-app imports. - templates/calendar: `getEventEndValidationMessage()` returns the right end-time validation message for the all-day, same-day, and multi-day cases. Extracted so the form and other date-time consumers can share the wording. * chore: add changeset for @agent-native/core per-key sync * test(core/poll-handler): update spec for per-key application_state emission Companion test update for 3886279. The mock execute handler now serves the new `SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ?` query shape, and the assertion expects the per-key `{source: 'app-state', key: '__screen_refresh__', owner: 'test@example.com'}` event instead of the legacy `key: '*'` wildcard. * fix(calendar/forms): use detailed end-time validation message in create + edit CreateEventDialog and EventDetailPopover both replace the generic 'End must be after start' toast with `getEventEndValidationMessage()` (added earlier on this branch), which picks the right phrasing for the all-day, same-day-different-time, and multi-day cases. * fix(calendar/sidebar): close sidebar overlay when the Google-accounts settings link is clicked `GoogleAccountsSection` now accepts an `onClose` callback and calls it from the per-account settings IconLink. Without this the sidebar's mobile overlay stayed open over /settings after the navigation completed, hiding the page the user just opened. * feat(clips): thread cameraBubbleSize selection into web + desktop recorders - templates/clips/app/routes/record.tsx now passes the user's cameraSize selection ('sm' / 'md' / 'lg') into the RecorderEngine, completing the wiring added in 7c5821e. - templates/clips/desktop/src/lib/recorder.ts: for native (Tauri) screen-camera recordings, the desktop recorder reads the saved `bubble_size` setting via `invoke('load_bubble_size')` and feeds it to `createCameraCompositeStream` so the saved video matches the in-recorder bubble preview. The composite stream's cleanup is registered with the recorder's `streamCleanups` queue so it stops with the rest of the capture pipeline. * fix(content/sidebar): keep deeply nested page rows reachable via horizontal scroll When a document tree was nested many levels deep, the indent (`depth * 16 + 12`) pushed the row text past the right edge of the fixed-width sidebar and the row clipped — the user had no way to see (let alone click) the inner pages. - `DocumentTreeItem`: tighten the per-level indent from 16px to 12px (matches the column tooltip target spacing) and give each row a `min-w-56` so its label keeps its layout width regardless of parent shrinkage. - `DocumentSidebar`: wrap the tree in a `min-w-full w-max` container so the inner list sizes to the widest row instead of clamping to the sidebar's visible width. - `scroll-area.tsx`: also render a horizontal `<ScrollBar>` so users can scroll the deep rows into view. - New `DocumentSidebar.layout.test.ts` source-string spec to lock in the four cooperating bits and catch silent regressions. * fix(core/voice): cancel during 'transcribing' actually drops the in-flight transcript `cancel()` previously returned early for any state other than `recording` / `starting`, so once the recording ended and the network POST to the transcription endpoint started, the user's cancel click was a no-op — the response would still arrive, get inserted into the composer, and trip up the user. - Add `transcribing` to the gating set in `cancel()` so a cancel-while-transcribing reaches the `cancelledRef.current = true` setter. - In both the success and error branches of the fetch (`then` text insert and `catch` live-snapshot fallback), check `cancelledRef.current` immediately after the await. If true, reset to idle and bail without forwarding the transcript to the composer. This avoids both the 'cancelled but text still appeared' bug and the analogous case where a live snapshot fallback would write after the user cancelled. * polish: composite-aware recorder dimensions, doc comment, calendar validator test - templates/clips/app/components/recorder/recorder-engine.ts: `readDimensions()` now prefers the combined stream's video track, which under screen+camera mode is the composited display+camera canvas (not the raw display feed). Keeps the recorder's reported dimensions matching what's actually written to disk. - templates/clips/desktop/src/app.tsx: doc-comment refresh — the camera stream isn't 'borrowed' for the video track anymore; it feeds the composite that MediaRecorder reads from. - templates/calendar/app/lib/event-form-utils.test.ts: vitest spec for `getEventEndValidationMessage`, covering the equal-times same-day case and the all-day end-before-start case. * chore: add changeset for voice-dictation cancel-during-transcribe fix * feat(core/composer): inline image attachments + attachment-only composer-mode sends + cancel active voice on submit - PromptComposer: extract `buildPromptComposerSubmission()` helper that flattens attachments into the outgoing message: pasted-text files inline as fenced blocks, small text files inline via `formatInlineTextFile`, image files inline via the new `formatInlineImageFile` (data URL wrapped in an `<uploaded-image name=... contentType=...>` block). Real File attachments fall through as file uploads. Refactored submission path consumes this helper so the same flattening applies regardless of entry point. - TiptapComposer: add a small `cancelActiveVoice()` helper called on every send / intercept path so an in-progress voice dictation gets cancelled when the user manually submits (was previously inserting the late transcript on top of the just- sent message). Pairs with the cancel-during-transcribe fix shipped earlier on this branch. - TiptapComposer: allow composer-mode submission with no text but with attachments. Default to 'Use the attached context.' as the prompt when text is empty; route the build through `composerRuntime.setText` + `composerRuntime.send` (instead of the plain `sendToAgentChat` helper) so the attachments survive the wrap in the composer-mode prefix + `<context>` block. * polish(clips): prettier reformat camera-composite + fall back to displayStream dimensions - templates/clips/{app,desktop}/.../camera-composite.ts: prettier reformat (no behavior changes — just rewrapping the long-line ternary, object-type signature, and the error throw). - templates/clips/desktop/src/lib/recorder.ts: when the primary video track doesn't report `width`/`height` (some macOS ScreenCaptureKit sources omit them under composite mode), fall back to the underlying display stream's settings so the persisted backup metadata still carries dimensions. * chore: add changeset for composer inline-image attachments + voice-cancel-on-submit * fix(core/AssistantChat): hide empty user-message bubble when only injected context remains A user message whose text content was nothing but an injected `<context>...</context>` block (sent via composer mode with only attachments — e.g. "summarize this file" with no prose) would render an empty grey bubble in the chat, since `UserMessageText` strips the context tags before display. - Extract `displayableUserMessageText()` helper so both `UserMessageText` and `UserMessage` can ask 'is there anything to show?' against the same stripping logic. - `UserMessage` now computes `hasDisplayableText` over every text part and: - Skips rendering the bubble + copy-handler entirely when the answer is no — only the attachment chips remain visible. - Skips the height-measure effect and the expand/collapse button so they don't run against a non-existent bubble. - Pairs with the composer-side change shipped earlier on this branch that allows attachment-only composer-mode sends. * chore: add changeset for AssistantChat empty-bubble fix * fix(security/sharing): scope owner-match by active orgId so personal queries don't leak org rows `accessFilter()` and `resolveAccess()` both treated 'this row's ownerEmail equals the current user' as sufficient proof of access, without checking whether the resource's `orgId` matched the request's active org context. A user with both a personal scope and one or more org memberships could therefore see their org- scoped resources while in personal scope, and vice versa — every resource they had ever owned showed up regardless of the active workspace. - New `ownerScopeFilter()` helper in the SQL list path: `ownerEmail = ? AND (orgId IS NULL OR orgId = ?)` keyed off `ctx.orgId`. Personal scope (no `orgId`) emits `orgId IS NULL`; org scope emits `orgId = ?` with the active org. Wrapped in an AND with the existing owner-email clause. - New `ownerMatchesActiveScope()` mirror for `resolveAccess()`'s read-by-id path, applied to the early `role: 'owner'` return so a stale share-link to a resource in another scope no longer short-circuits the access check. * test(core/sharing): cover orgId-scoped owner access for listVisible + resolveAccess Companion spec for aa93f2f. Adds three rows covering each scope (`owned` org=`orgId`, `owned-other-org`, `owned-solo` org=null) and asserts that: - `listVisible({userEmail, orgId})` only returns the matching-org owned row plus normal org-share rows; cross-org rows are filtered out. - `listVisible({userEmail, orgId: otherOrgId})` only returns the other-org row; the personal-scope row stays hidden. - `listVisible({userEmail})` (personal scope) only returns the null-org row. - `resolveAccess('doc-owned-other-org')` and `resolveAccess('doc-owned-solo')` both return `null` from inside the `{userEmail, orgId}` context where neither resource's scope matches. * test(core/composer): extract displayableComposerModeMessage helper + cover composer-mode + chat-display - Extract `displayableComposerModeMessage()` from `TiptapComposer`'s send path so the prefix-+-attachment fallback ('Use the attached context.') is a unit-testable function. - `TiptapComposer.spec.ts` (modified) covers the three cases: empty text + 0 attachments, empty text + attachments, non-empty text. - New `PromptComposer.spec.ts` covers `buildPromptComposerSubmission()` — pasted-text inlining, small-text-file inlining, image-file inlining via `<uploaded-image>` block, and real File pass-through. - New `AssistantChat.display.spec.ts` covers `displayableUserMessageText()` — context-stripping, whitespace trim, multiple-context-block stripping. - `useVoiceDictation.ts` + `AssistantChat.tsx`: prettier-only reformat (no behavior changes). * fix(core/AssistantChat): correct two useEffect deps after empty-bubble fix Two paired dependency fixes to the empty-bubble change I made in 091b9b8: - `MarkdownText`'s `injectMarkdownStyles` effect doesn't depend on `hasDisplayableText` — that variable isn't in scope and the effect should only run once. Revert deps to `[]`. - `UserMessage`'s height-measure ResizeObserver effect was missing `hasDisplayableText` in its deps. It guards on `hasDisplayableText` at the top, so the deps array should re-run the effect when that flips. * feat(clips): create-recording accepts spaceIds + wire through web + desktop + test - `create-recording` action: new optional `spaceIds: string[]` arg persisted via `stringifySpaceIds()` (already exported from recordings server lib) so the recording row is attached to one or more Spaces at creation time instead of after a follow-up attach call. - `templates/clips/app/routes/record.tsx`: pass `spaceIds: []` from the live screen-recording and file-upload flows. Empty default preserves existing behavior. - `templates/clips/desktop/src/lib/recorder.ts`: same `spaceIds: []` default in the Tauri recorder's POST body. - `templates/clips/AGENTS.md`: `create-recording` arg row now includes `--spaceIds`. - `templates/clips/actions/create-recording.test.ts`: spec covers passing a list of spaceIds and verifies they round-trip into the persisted row's `space_ids` column. * feat(content/list-documents): include accessRole/canEdit/canManage on each row `list-documents` previously returned only document metadata, leaving the UI to compute 'can I edit this doc?' separately or optimistically — which led to subtle bugs where viewers saw edit affordances they couldn't actually use. The action now joins access-resolution into the listing: - Owner detection: `ownerEmail === userEmail` AND owner-scope matches the active orgId (mirrors the `accessFilter` change in aa93f2f). - Per-share lookup: a single bulk query against `documentShares` fetches user + org grants for all returned documents in one round-trip; `strongerRole()` picks the highest grant per resource id. - Visibility floor: org-scoped documents the viewer accesses via visibility alone get a `viewer` role. - Final shape adds `accessRole`, `canEdit`, `canManage` so the sidebar / list UI can render the right affordances directly off the listing response. * feat(content/create-document): child docs inherit parent owner + org + visibility + shares When a child document is created under a parent the current user has editor access to, the child previously got created with `ownerEmail = currentUser`, `orgId = currentUser's active orgId`, and visibility `private` — so non-owner editors could create children but never see them again (the access filter dropped them), and org-shared parents produced personal-only children. The action now: - Reads the parent's `ownerEmail`, `orgId`, and `visibility` from the access-checked parent resource and re-uses them for the new child so the child lives in the same workspace + visibility bucket as its parent. - Copies every grant from `documentShares` for the parent onto the child (same principal/type/role) so collaborators keep the same access they had on the parent without a separate share operation. `createdBy` records the actual creator. - Returns `accessRole` / `canEdit` / `canManage` alongside the document (matches the shape `list-documents` now returns in f1d949b) so the UI doesn't have to re-resolve access right after creation. * fix(content/sidebar): optimistic delete + role-aware reorder buttons - Move up / down arrows now only enable when both the current doc and its neighbor have `canEdit`. Previously a viewer saw the arrows enabled on any doc with siblings, then got a 403 toast when they clicked. Uses the new `canEdit` field from `list-documents` (f1d949b). - Delete is now optimistic: `collectDocumentSubtreeIds()` finds every descendant of the deleted node (no orphans), prunes them from the React-Query cache before awaiting the mutation, and navigates to a sensible next document (first favorite, else first-by-position) if the current page is inside the deleted subtree. `flushSync: true` so navigation completes before the query cache invalidates and re-paints. - On error the optimistic cache change is rolled back (full invalidate), the user is navigated back to the original active doc, and the toast surfaces the actual error message instead of the generic 'Failed to delete page'. * fix(content/sidebar): treat missing canEdit as editable + populate access fields on optimistic new-doc placeholder Two small follow-ups to 19b8381 + c0d443c: - Reorder availability flips the polarity slightly so a missing `canEdit` (older cached payloads from before list-documents started returning the field) is treated as editable rather than not. Without this, the arrows momentarily disabled themselves for everyone after the f1d949b deploy until the cache refreshed. - The optimistic placeholder row created during 'New document' now carries `accessRole: 'owner'`, `canEdit: true`, `canManage: true` so the sidebar's permission-aware affordances light up immediately on the just-created row instead of flickering between disabled and enabled while the server response comes back. * feat(design/systems): include access role + restrict selection actions to manageable systems - `list-design-systems` action now resolves each row's access role via `resolveAccess("design-system", id)` and exposes `accessRole` + `canManage` (owner / admin only) on every returned item, in both compact and full shapes. The bulk parallelization is intentional — the access check is in-process and the list payload is small. - `DesignSystems` page: selection-mode and bulk-delete affordances are now gated on `canManage`. A viewer/editor of a shared design system sees the system in the list but can't tick its checkbox, trigger the master 'select all', or open the Select header button at all when nothing in the list is theirs to manage. `toggleSystemSelection` early-returns when the row isn't manageable, and the deps array is updated accordingly. * fix(design/systems): surface actual error in delete toasts Both single-delete and bulk-delete handlers swallowed the error and only invalidated the cache. Users saw the row reappear after the optimistic remove but got no toast explaining why. Both handlers now also fire a sonner `toast.error` with the underlying error message in the description so the failure is visible. * style(design/systems): prettier reformat of toggleSystemSelection useCallback Prettier rewrapped the inline-arrow-with-deps useCallback into the multi-line form. No behavior change. * fix(core/sharing-spec): preserve explicit null orgId in insertDoc + alphabetical assertion Two follow-ups to 8337a53: - `insertDoc` was applying `values.orgId ?? orgId` to its inputs, so an explicit `orgId: null` argument was being silently replaced by the test fixture's default `orgId`. The `owned-solo` (intentionally `orgId: null`) row was therefore being inserted with the wrong scope, masking the regression the test is meant to catch. Switch to the `=== undefined` check so explicit `null` survives. - Other-org listVisible assertion: list comes back ordered by id, so the `owned-other-org` / `other-org` pair needs to be in that order alphabetically — flip to match. - Prettier-only reformat of the assertion against `resolveAccess("doc-owned-other-org")`. * fix(content/sidebar): gate per-row actions on canEdit + canManage Continues the access-aware sidebar work earlier on this branch. Each tree row now reads `canEdit` and `canManage` (admin/owner) from the document and shows only the affordances it owns: - `hasMenuActions`: hide the entire `⋯` dropdown trigger when the row has neither edit nor manage capability (viewer-only access). - `canEdit`: Add sub-page, Move up, Move down, and Favorite items only render for editors. The hover `+` quick-action next to the `⋯` trigger is also gated here. - `canManage`: only owners/admins see the destructive Delete row. - The separator between the editor block and the manage block only renders when both are present, so the menu doesn't show a trailing separator when only one of the two is available. * test(content/sidebar): lock in canEdit/canManage gating in DocumentTreeItem Source-string spec ensures the per-row `canEdit` / `canManage` gating from 091c889 stays wired up — the source must keep `const canEdit = node.canEdit !== false`, `const canManage =`, and the two conditional render guards, so future refactors don't silently re-expose Add/Move/Delete to viewers. * fix(slides/DeckContext): backfill open deck from /api/decks/:id if listing omitted it The list endpoint scopes results by the user's active org + sharing grants, which means a deck the user opens via a direct URL (e.g. someone shared a link cross-org, or visibility flipped between scopes) can be missing from the list payload — and the editor would render an empty deck list with the URL still pointing at a non-existent deck id. - New `deckIdFromPathname()` parses the deck id from `/deck/<id>` with URL-decoding fallback. - New `includeOpenDeckIfMissing()` accepts the loaded list, the currently-open deck id (from `window.location.pathname`), and a fetcher that defaults to `fetchDeckFromAPI(id)`. If the open id isn't in the list, it does a single GET against `/api/decks/:id` and appends the deck if it comes back. The fetcher param is wired up so unit tests can stub it. - Used in the initial `useEffect` mount path so the editor sees the open deck on first paint instead of waiting for the fallback poll. - The existing `readOpenDeckId` closure now delegates to `deckIdFromPathname()` to share the same parsing rules. * test(slides/DeckContext): cover deckIdFromPathname + includeOpenDeckIfMissing helpers Companion spec for a493046. Covers: - `deckIdFromPathname`: editor route, present sub-route, URL-encoded id, and a non-matching path returning null. - `includeOpenDeckIfMissing`: backfills the open deck when the list omits it (and only then — no extra fetch when the list already contains the open deck). * fix(content/p.$id): render private-document notice for signed-in viewers without access Previously a signed-in user who didn't have access to a non-public document got a generic 404 — even though the loader knows the doc exists, it just isn't theirs to see. That made shared-link mishaps confusing ('the link is broken' vs 'I don't have access'). - Loader now returns `{ document: null, unavailable: { reason: 'private' } }` for the signed-in-no-access branch instead of throwing 404. - New `PrivateDocumentNotice` component renders a centered card with an `IconLock`, a clear 'This document is private' headline, and a one-line ask-the-owner-to-share hint. Same empty-state visual language as the rest of the template's unavailable states. - `PublicDocumentPage` returns the notice when `document` is null. The signed-out branch already redirects to sign-in (unchanged) and the genuinely-missing branch still throws 404. * feat(content/editor): inline /generate <prompt> shortcut + extracted parser Lets users type `/generate <prompt>` mid-document and hit Enter to fire the agent generation without opening the slash command menu — same flow as the menu-driven Generate item, just without the popover step. - New `parseInlineGeneratePrompt(textBeforeCursor)` helper matches `/^/generate\s+([\s\S]+)$/` and returns the trimmed prompt or null. Exported so the spec can pin the grammar. - `readInlineGenerateCommand()` walks the current editor state, bails on non-empty selection or non-textblock parents, and returns the prompt-range pair if the line is a `/generate <prompt>` command. - `submitGeneratePrompt(prompt)` shares the trimmed-prompt + document-context send path with the existing menu Generate item — toast on missing documentId, fire `send()` with the same update-document instructions. - New `SlashCommandMenu.test.ts` covers the parser: happy path, whitespace trim, and three rejection cases (`/generate` with no prompt, a different slash command, and `/generate` not at the start of the line). * fix(content/editor): preserve editor focus when clicking slash-command items `onMouseDown` now `preventDefault()`s so clicking a slash-menu item doesn't blur the editor before the click handler runs. The command's editor transactions (e.g. set heading, insert table) depend on the editor still owning the selection — without this the click landed after focus had moved to the menu button, the range was lost, and the inserted node was attached to the wrong position. * fix(clips/record): drop duplicate spaceIds key in create-recording POST bodies Two call-sites in record.tsx (live recording + file upload) had both an empty `spaceIds: []` (from my earlier 91b685f commit that wired through the schema default) AND a later `spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined` from the concurrent agent's work that reads the active space from the URL. Result was a duplicate-key object literal that TypeScript hard-errors (`TS1117`) and prettier was quiet about. Keep the URL-aware spaceIds variant — it's the actual feature wiring — and drop the trivial `[]` defaults. * style: prettier reformat of use-db-sync, access, and list-documents Pure formatting churn from running prettier — three call sites got reflowed without behavior changes: - `use-db-sync`: `hasAppStateEvent` predicate joined to one line. - `access.ts`: `accessFilter` owner clause split across more arguments per line. - `list-documents`: `strongerRole` signature reflowed to single line.
1 parent 4f8e18f commit 2eb5064

54 files changed

Lines changed: 2640 additions & 409 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
name: address-feedback
3+
description: >-
4+
Triage feedback docs or pasted feedback into bugs to fix, UX suggestions to
5+
propose, unclear questions, and skipped noise; verify bugs, check Sentry when
6+
relevant, and keep UI changes minimal.
7+
---
8+
9+
# Address Feedback
10+
11+
Use this skill when the user shares a feedback document, issue, thread, or pasted notes and asks you to address the feedback.
12+
13+
The default posture is judgment plus action: fix clear, verified bugs you agree with; propose UX changes with rationale; skip or flag low-signal, unclear, or out-of-scope items.
14+
15+
## Prerequisites
16+
17+
- If no link or feedback text is provided, ask for it.
18+
- Read the repo `AGENTS.md` before touching code.
19+
- Use the relevant connector/plugin/skill for the source when available, instead of scraping authenticated pages.
20+
21+
## Steps
22+
23+
1. Read the feedback source.
24+
25+
| Source | Reader |
26+
| --- | --- |
27+
| Notion link | Notion connector or Notion skill |
28+
| Google Docs/Drive link | Google Drive connector or Google Docs skill |
29+
| Linear link | Linear connector if installed; otherwise ask for pasted content |
30+
| GitHub issue or PR | GitHub connector or `gh issue view` / `gh pr view` |
31+
| Slack thread | Slack connector |
32+
| Public URL | Web browsing |
33+
| Pasted text | Read directly |
34+
35+
Use web browsing only for public URLs. Auth-gated docs usually need their matching connector.
36+
37+
2. Categorize every actionable item.
38+
39+
- **Bug**: Broken behavior, crash, wrong data, dead link, package/API mismatch, or captured exception. Verify and fix when you agree.
40+
- **UX suggestion**: Design, discoverability, workflow, or feature feedback. Propose the cleanest version first unless the user explicitly asked you to implement UX changes.
41+
- **Question or unclear**: Missing detail, contradictory feedback, or behavior you cannot inspect. Ask or flag it.
42+
- **Out of scope**: Outside this repo, already shipped, intentionally unsupported, or too low-signal. Note briefly and skip.
43+
44+
3. Check Sentry when the feedback smells like an error.
45+
46+
- Use the Sentry skill/plugin if available, or the repo's Sentry scripts if documented.
47+
- Search by route, stack symbol, error text, and symptom keywords.
48+
- Default org is `builder-io` unless the user specifies another.
49+
- Cite issue IDs or links when you find a match.
50+
- If nothing matches, say that plainly.
51+
52+
4. Fix only the clear bugs you agree with.
53+
54+
- Verify before fixing: reproduce locally, read the relevant code, inspect logs, or confirm with a stack trace.
55+
- Keep each fix narrow and mapped to a feedback item.
56+
- Follow existing project conventions and nearby patterns.
57+
- Do not switch branches, stash, reset, force-push, or open a PR unless the user asks.
58+
- Add or update focused tests when the bug risk warrants it.
59+
60+
5. Treat UX feedback with restraint.
61+
62+
Do not solve UX problems by adding more visible controls, helper text, banners, top-level nav, or always-open panels by default. Prefer cleaner interaction models:
63+
64+
- Make the existing primary action more discoverable.
65+
- Remove competing elements so the right action stands out.
66+
- Move secondary actions into `DropdownMenu`, `Popover`, `Sheet`, `Collapsible`, or tabs.
67+
- Improve empty states around one clear action.
68+
- Use progressive disclosure for optional or advanced controls.
69+
70+
When proposing a UX change, write it as: what to change, why it helps, and the tradeoff. Keep each proposal short.
71+
72+
6. Verify changed behavior.
73+
74+
- Run the smallest relevant test or typecheck command.
75+
- For UI fixes, verify in a browser when feasible and inspect the actual screen.
76+
- If you cannot run a useful verification, say why.
77+
78+
## Report Format
79+
80+
Keep the final report short:
81+
82+
```md
83+
## Bugs Fixed
84+
- [feedback item] - [what changed, file:line]
85+
86+
## Bugs Flagged But Not Fixed
87+
- [feedback item] - [why]
88+
89+
## UX Suggestions
90+
- [feedback item] -> [proposed change]
91+
92+
## Skipped
93+
- [feedback item] - [reason]
94+
```
95+
96+
Only include sections that have content. The user can read the diff; do not write a second feedback document.
97+
98+
## Avoid
99+
100+
- Do not agree with every suggestion by default.
101+
- Do not bundle unrelated cleanups.
102+
- Do not implement UX changes that make an important screen busier without explicit user approval.
103+
- Do not claim a UI change is done without browser verification when a local app can be run.
104+
- Do not invent Sentry matches, affected users, or reproduction steps.
105+
106+
## Related Skills
107+
108+
- `github:gh-address-comments` for GitHub PR review threads.
109+
- `github:gh-fix-ci` for failing GitHub checks.
110+
- `sentry:sentry` for production error investigation.
111+
- `frontend-design` for approved UI implementation work.
112+
- `qa` for broader browser verification.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@agent-native/core": patch
3+
---
4+
5+
`AssistantChat`: hide the empty user-message bubble when the text content is nothing but an injected `<context>...</context>` block. Previously, sending an attachment-only composer-mode message (e.g. `/code` with a file but no prose) rendered an empty grey bubble in the chat after the context tags were stripped. The message now skips the bubble + expand/collapse UI entirely when the only attachment is context; attachment chips still render above.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@agent-native/core": minor
3+
---
4+
5+
`PromptComposer` + `TiptapComposer`: inline image attachments, attachment-only composer-mode sends, and active-voice cancellation on submit. Image files attached to the composer are now sent inline as `<uploaded-image name=… contentType=…>` data-URL blocks alongside the existing pasted-text / inline-text flattening. Composer modes (`/code`, `/research`, etc.) now also accept submissions with no text when attachments are present — the default prompt becomes "Use the attached context." and the attachments survive the wrap in the mode's prefix + `<context>` block. Every send / build intercept path also cancels any in-flight voice dictation so a late transcript can't land on top of the just-sent message.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@agent-native/core": patch
3+
---
4+
5+
`useDbSync` + server poll: per-key invalidation for application_state one-shot commands. The poll loop now emits one event per changed (key, owner) pair instead of a single `key: "*"` wildcard, and the client only invalidates `navigate-command` / `show-questions` / `__set_url__` queries when those specific keys actually change. Noisy app-state keys (template-specific UI state, per-tab flags) no longer wake the navigation / question readers on every poll cycle.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@agent-native/core": patch
3+
---
4+
5+
`useVoiceDictation`: cancelling while the transcription request is in flight now actually drops the response. Previously `cancel()` returned early for any state other than `recording` / `starting`, so once the network POST started, a cancel click was a no-op and the transcribed text would still be inserted into the composer after the user cancelled. The fetch handlers (both success and live-snapshot fallback) now check `cancelledRef` immediately after the await and bail without forwarding.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Agent skills in `.agents/skills/` provide detailed guidance. Read the relevant s
7070

7171
| Skill | When to use |
7272
| ---------------------- | ------------------------------------------------------------- |
73+
| `address-feedback` | Triage feedback docs into bugs to fix and UX proposals |
7374
| `adding-a-feature` | Adding any new feature (the four-area checklist) |
7475
| `actions` | Creating or running agent actions |
7576
| `storing-data` | Adding data models, reading/writing config or state |
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// @vitest-environment happy-dom
2+
3+
import { describe, expect, it } from "vitest";
4+
import { displayableUserMessageText } from "./AssistantChat.js";
5+
6+
describe("displayableUserMessageText", () => {
7+
it("treats context-only messages as empty for user bubble display", () => {
8+
expect(
9+
displayableUserMessageText(
10+
"\n\n<context>\nHidden attachment instructions\n</context>",
11+
),
12+
).toBe("");
13+
});
14+
});

packages/core/src/client/AssistantChat.tsx

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,9 +1713,7 @@ const plainMentionPattern = /((?:^|(?<=\s))@(\w+))/g;
17131713

17141714
function UserMessageText({ text }: { text: string }) {
17151715
// Strip injected <context>...</context> blocks before display
1716-
const displayText = text
1717-
.replace(/<context>[\s\S]*?<\/context>\n?/g, "")
1718-
.trim();
1716+
const displayText = displayableUserMessageText(text);
17191717

17201718
const parts: React.ReactNode[] = [];
17211719
let lastIndex = 0;
@@ -1779,6 +1777,10 @@ function UserMessageText({ text }: { text: string }) {
17791777
return <>{parts.length > 0 ? parts : displayText}</>;
17801778
}
17811779

1780+
export function displayableUserMessageText(text: string): string {
1781+
return text.replace(/<context>[\s\S]*?<\/context>\n?/g, "").trim();
1782+
}
1783+
17821784
function UserMessageAttachments() {
17831785
const messageRuntime = useMessageRuntime();
17841786
const msg = messageRuntime.getState();
@@ -1837,11 +1839,19 @@ function UserMessage() {
18371839
const [isExpandable, setIsExpandable] = useState(false);
18381840
const contentRef = useRef<HTMLDivElement>(null);
18391841
const messageRuntime = useMessageRuntime();
1840-
const timestamp = formatMessageTimestamp(messageRuntime.getState().createdAt);
1842+
const message = messageRuntime.getState();
1843+
const timestamp = formatMessageTimestamp(message.createdAt);
1844+
const hasDisplayableText =
1845+
message.content
1846+
?.filter((part): part is { type: "text"; text: string } => {
1847+
return part.type === "text" && typeof part.text === "string";
1848+
})
1849+
.some((part) => displayableUserMessageText(part.text).length > 0) ??
1850+
false;
18411851

18421852
useEffect(() => {
18431853
const el = contentRef.current;
1844-
if (!el) return;
1854+
if (!el || !hasDisplayableText) return;
18451855

18461856
const measure = () => {
18471857
setIsExpandable(el.scrollHeight > 200);
@@ -1851,7 +1861,7 @@ function UserMessage() {
18511861
const observer = new ResizeObserver(measure);
18521862
observer.observe(el);
18531863
return () => observer.disconnect();
1854-
}, []);
1864+
}, [hasDisplayableText]);
18551865

18561866
return (
18571867
<div
@@ -1860,41 +1870,45 @@ function UserMessage() {
18601870
>
18611871
<div className="max-w-[85%]">
18621872
<UserMessageAttachments />
1863-
<div
1864-
className="relative rounded-lg bg-accent px-3 py-2 text-sm leading-relaxed text-foreground"
1865-
onCopy={(e) => {
1866-
const selection = window.getSelection();
1867-
if (!selection || selection.rangeCount === 0) return;
1868-
const fragment = selection.getRangeAt(0).cloneContents();
1869-
const mentions = fragment.querySelectorAll("[data-mention-label]");
1870-
if (mentions.length === 0) return;
1871-
e.preventDefault();
1872-
mentions.forEach((el) => {
1873-
el.textContent = `@${el.getAttribute("data-mention-label")}`;
1874-
});
1875-
const div = document.createElement("div");
1876-
div.appendChild(fragment);
1877-
e.clipboardData.setData("text/plain", div.textContent || "");
1878-
}}
1879-
>
1873+
{hasDisplayableText && (
18801874
<div
1881-
ref={contentRef}
1882-
className={cn(
1883-
"whitespace-pre-wrap break-words",
1884-
!expanded && isExpandable && "max-h-[200px] overflow-hidden",
1885-
)}
1875+
className="relative rounded-lg bg-accent px-3 py-2 text-sm leading-relaxed text-foreground"
1876+
onCopy={(e) => {
1877+
const selection = window.getSelection();
1878+
if (!selection || selection.rangeCount === 0) return;
1879+
const fragment = selection.getRangeAt(0).cloneContents();
1880+
const mentions = fragment.querySelectorAll(
1881+
"[data-mention-label]",
1882+
);
1883+
if (mentions.length === 0) return;
1884+
e.preventDefault();
1885+
mentions.forEach((el) => {
1886+
el.textContent = `@${el.getAttribute("data-mention-label")}`;
1887+
});
1888+
const div = document.createElement("div");
1889+
div.appendChild(fragment);
1890+
e.clipboardData.setData("text/plain", div.textContent || "");
1891+
}}
18861892
>
1887-
<MessagePrimitive.Parts
1888-
components={{
1889-
Text: UserMessageText,
1890-
}}
1891-
/>
1893+
<div
1894+
ref={contentRef}
1895+
className={cn(
1896+
"whitespace-pre-wrap break-words",
1897+
!expanded && isExpandable && "max-h-[200px] overflow-hidden",
1898+
)}
1899+
>
1900+
<MessagePrimitive.Parts
1901+
components={{
1902+
Text: UserMessageText,
1903+
}}
1904+
/>
1905+
</div>
1906+
{!expanded && isExpandable && (
1907+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 rounded-b-lg bg-gradient-to-t from-accent via-accent/90 to-transparent" />
1908+
)}
18921909
</div>
1893-
{!expanded && isExpandable && (
1894-
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 rounded-b-lg bg-gradient-to-t from-accent via-accent/90 to-transparent" />
1895-
)}
1896-
</div>
1897-
{isExpandable && (
1910+
)}
1911+
{hasDisplayableText && isExpandable && (
18981912
<button
18991913
type="button"
19001914
onClick={() => setExpanded((prev) => !prev)}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// @vitest-environment happy-dom
2+
3+
import { describe, expect, it } from "vitest";
4+
import { buildPromptComposerSubmission } from "./PromptComposer.js";
5+
6+
describe("buildPromptComposerSubmission", () => {
7+
it("inlines image-only submissions so standalone flows receive a prompt", async () => {
8+
const file = new File(["fake image"], "sketch.png", {
9+
type: "image/png",
10+
});
11+
12+
const result = await buildPromptComposerSubmission({
13+
text: "",
14+
attachments: [
15+
{
16+
id: "sketch.png",
17+
name: "sketch.png",
18+
type: "image",
19+
file,
20+
},
21+
],
22+
});
23+
24+
expect(result.files).toEqual([file]);
25+
expect(result.text).toContain(
26+
'<uploaded-image name="sketch.png" contentType="image/png">',
27+
);
28+
expect(result.text).toContain("data:image/png;base64,");
29+
});
30+
});

0 commit comments

Comments
 (0)