Skip to content

feat(frontend): @agenta/chat — the shared chat engine, composer and approval card - #5870

Draft
ardaerzin wants to merge 4 commits into
pkg/session-surfacesfrom
pkg/chat-engine
Draft

feat(frontend): @agenta/chat — the shared chat engine, composer and approval card#5870
ardaerzin wants to merge 4 commits into
pkg/session-surfacesfrom
pkg/chat-engine

Conversation

@ardaerzin

Copy link
Copy Markdown
Contributor

@agenta/chat is the chat runtime as a package: the message engine, the composer, and the
approval card. It is the dependency both the OSS chat slice (next lane) and /m build on.

antd-free by construction — the mobile app cannot take an antd dependency.
Not run in a browser — static gates only (pnpm lint-fix 24/24, tsc --noEmit clean
for @agenta/shared, ui, entities, entity-ui, settings-ui, oss, ee, mobile).

Stacked on pkg/session-surfaces; review only this lane's diff.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Error Error Aug 10, 2026 2:58pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b2ac4a2a-50c9-4385-a9d7-131f3cebaf91

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added support for image, audio, document, and other file attachments with tailored size limits and previews.
    • Added attachment uploads with progress tracking, cancellation, retries, and error handling.
    • Added shared chat composer, attachment tray, audio playback, and approval-card components.
    • Added persistent attachment handling and improved transcript recovery, error display, and message streaming.
  • Bug Fixes
    • Improved validation, upload cancellation, retry timing, and attachment naming.
    • Preserved error-only conversation turns and approval states during replay.
  • Tests
    • Expanded coverage for attachments, uploads, approvals, transcript replay, and session persistence.

Walkthrough

The PR adds shared agent-chat entry points, attachment upload and preview infrastructure, reusable composer and approval components, transcript error handling, session revalidation, and smoother live text streaming.

Changes

Shared agent-chat platform

Layer / File(s) Summary
Agent-chat entry points and shared plumbing
web/packages/agenta-playground/..., web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts, web/packages/agenta-chat/src/model/*, web/packages/agenta-chat/src/state/*, web/packages/agenta-chat/src/assets/*
The playground exposes focused agent-chat APIs. Execution headers use a dedicated atom. Canonical shared-module comments and shared helpers were updated.
Attachment rules, transport, and reference parts
web/packages/agenta-chat/src/assets/attachmentRules.ts, web/packages/agenta-chat/src/assets/attachmentTransport.ts, web/packages/agenta-chat/src/assets/files.ts, web/packages/agenta-chat/src/model/attachments.ts, web/packages/agenta-chat/src/state/sessionEphemera.ts, web/packages/agenta-chat/tests/unit/assets/*, web/packages/agenta-chat/tests/unit/model/attachments.test.ts
Attachments use per-kind limits, validated multipart uploads, retry metadata, durable content URLs, provider metadata, and unique staged-upload IDs.
Upload lifecycle and composer UI
web/packages/agenta-chat/src/hooks/useAttachmentUploads.ts, web/packages/agenta-chat/src/hooks/useComposerAttachments.ts, web/packages/agenta-chat/src/components/ChatComposer.tsx, web/packages/agenta-chat/src/components/ComposerAttachments.tsx, web/packages/agenta-chat/src/components/AudioPlayer.tsx, web/packages/agenta-chat/package.json, web/packages/agenta-chat/src/components/index.ts, web/packages/agenta-chat/src/hooks/index.ts, web/packages/agenta-chat/tests/unit/hooks/*
The composer manages session-scoped staged files, upload progress, retries, drag and drop, previews, attachment references, and send gating. Shared components are exported from the package.
Conversation replay and approval handling
web/packages/agenta-chat/src/components/ApprovalCard.tsx, web/packages/agenta-chat/src/hooks/useAlwaysAllowTool.ts, web/packages/agenta-chat/src/hooks/useAgentConversation.ts, web/packages/agenta-chat/src/assets/transcriptToMessages.ts, web/packages/agenta-chat/src/transport/AgentChatTransport.ts, web/packages/agenta-chat/src/state/sessionMessages.ts, web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts, web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts, web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts, web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json
Conversation sending accepts prebuilt attachment parts and exposes guarded revalidation. Transcript replay preserves persisted errors, paused turns, approvals, and attachment records. Approval cards support payload previews, batch actions, redirect denial, and deferred always-allow grants. Live delta streams are paced.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatComposer
  participant useComposerAttachments
  participant uploadAttachment
  participant AgentConversation
  User->>ChatComposer: Select or paste files
  ChatComposer->>useComposerAttachments: Stage and validate files
  useComposerAttachments->>uploadAttachment: Upload multipart attachment
  uploadAttachment-->>useComposerAttachments: Return validated attachment reference
  useComposerAttachments-->>ChatComposer: Update preview and send gating
  User->>ChatComposer: Submit message
  ChatComposer->>AgentConversation: Send text and attachment parts
  AgentConversation-->>ChatComposer: Stream response and persist transcript
Loading

Possibly related PRs

  • Agenta-AI/agenta#5459: Both PRs implement the agent-chat attachment system, including rules, upload hooks, staged files, and composer previews.
  • Agenta-AI/agenta#5682: Both PRs evolve the shared @agenta/chat attachment, transport, hook, and component APIs.
  • Agenta-AI/agenta#5306: Both PRs modify shared session infrastructure for attachments, transcript replay, and playground agent-chat exports.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 60.00%.
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.
Description check ✅ Passed The description clearly explains the new shared chat runtime package, its components, intended consumers, and dependency constraints.
Title check ✅ Passed The title clearly identifies the new shared chat engine, composer, and approval card package.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pkg/chat-engine

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: 12

🧹 Nitpick comments (14)
web/packages/agenta-playground/src/agentChat.ts (1)

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

Reduce new implementation comments to one short line.

The new comments exceed the repository comment limit. Keep longer comments only for a genuinely surprising bug, race, or ordering constraint.

  • web/packages/agenta-playground/src/agentChat.ts#L1-L7: reduce the entry-point rationale to one short line.
  • web/packages/agenta-playground/src/state/execution/executionHeaders.ts#L3-L22: remove the in-code example and reduce the rationale to one short line.
  • web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts#L87-L88: use one short re-export comment.
  • web/packages/agenta-chat/src/transport/AgentChatTransport.ts#L207-L220: reduce the stream-smoothing rationale to one short line.
  • web/packages/agenta-chat/src/transport/AgentChatTransport.ts#L232-L233: use one short comment for delta matching.
  • web/packages/agenta-chat/src/model/parts.ts#L58-L60: reduce the performance rationale to one short line.

As per coding guidelines, “Keep in-code comments to at most one short line; use longer comments only for genuinely surprising constraints such as bugs, races, or ordering requirements.”

Source: Coding guidelines

web/packages/agenta-chat/src/assets/attachmentRules.ts (1)

146-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive isViewable from kindForType to keep one media-type table.

The predicate repeats the exact member list of KIND_TYPES.document plus image/. If KIND_TYPES.document changes later, this list silently drifts.

♻️ Proposed refactor
-export const isViewable = (mediaType: string): boolean =>
-    mediaType.startsWith("image/") ||
-    mediaType === "application/pdf" ||
-    mediaType.startsWith("text/") ||
-    mediaType === "application/json"
+export const isViewable = (mediaType: string): boolean => {
+    const kind = kindForType(mediaType)
+    return kind === "image" || kind === "document"
+}
web/packages/agenta-chat/src/assets/attachmentTransport.ts (1)

47-58: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Use the caller's limits for the 413 message, not the defaults.

errorForResponse reads DEFAULT_ATTACHMENT_LIMITS.maxBytes. validateIncoming accepts a caller-supplied AttachmentLimits, and useComposerAttachments documents that limits will later come from capability gating. If a host narrows the limits, the 413 text will state a size that does not match the enforced client limit.

Consider passing the active AttachmentLimits into uploadAttachment and through to errorForResponse.

web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts (1)

26-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the narrowed-limits typing.

The test declares kinds with as const and then spreads it again at the call site to get a mutable array. The later test at Lines 60-63 solves the same problem with an explicit array type. Use one style, for example kinds: ["image", "audio", "document"] as AttachmentKind[].

web/packages/agenta-chat/tests/unit/assets/files.test.ts (1)

158-195: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a negative case for attachmentIdForPart.

The suite covers the reference-part happy path. attachmentIdForPart also guards a missing providerMetadata, a non-object agenta value, and an empty-string attachmentId. A part with providerMetadata: {agenta: {attachmentId: ""}} must fall through to the URL-tail branch of filePartName. That branch is currently untested.

web/packages/agenta-chat/src/components/index.ts (1)

10-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Export the prop types for the two default-exported components.

ApprovalCard and ChatComposer re-export their prop types. ComposerAttachments and AudioPlayer do not. Consumers outside the package cannot type a wrapper around them. ComposerAttachmentsProps already exists in ComposerAttachments.tsx.

web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts (1)

74-85: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the exact count for the same-tick guard.

Two batches of Math.ceil(maxCount / 2) + 1 files stage exactly maxCount entries when the guard works. toBeLessThanOrEqual also passes if the hook stages far fewer files, for example if the second batch is dropped entirely. Use toBe(DEFAULT_ATTACHMENT_LIMITS.maxCount).

web/packages/agenta-chat/src/components/AudioPlayer.tsx (1)

50-67: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add an event or timeout exit path for the duration probe.

current updates are skipped while probingRef.current is true, but the probe only waits for finishProbe from a timeupdate after el.currentTime = 1e101. If that seek is clamped or otherwise does not emit timeupdate, this flag remains true and the elapsed-time display stops advancing. Use an additional completion event such as durationchange, or fall back after a short timeout, so the probe cannot leave the player in a paused timer state.

web/packages/agenta-chat/src/model/attachments.ts (1)

36-38: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

type and size have no producer or consumer.

toUploadFile in web/packages/agenta-chat/src/hooks/useComposerAttachments.ts never sets type or size, and ComposerAttachments.tsx derives both from originFileObj only. The declared fallback for rows whose blob is gone does not work today. Either populate the fields in toUploadFile and read them in the tile renderer, or drop the fields until a serialized path exists.

web/packages/agenta-chat/src/hooks/useComposerAttachments.ts (2)

39-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the leftover cast.

StagedUpload["originFileObj"] is File | undefined, so a File assigns directly. The cast is a shim left from the antd UploadFile type.

♻️ Proposed cleanup
-    originFileObj: file as UploadFile["originFileObj"],
+    originFileObj: file,

As per coding guidelines: for workspace packages, "avoid any and legacy compatibility shims".

Source: Coding guidelines


242-262: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

The hook returns a new object and new handler identities on every render.

addFiles, removeFile, uploadExtraFiles, bindDropTarget, and clearAttachments are recreated each render, and the returned object literal is a new reference each render. ChatComposer passes addFiles and removeFile straight into ComposerAttachments, so no consumer can memoize on them, and hosts that receive the whole attachments object re-render on every parent render.

Wrap the handlers in useCallback and the result in useMemo.

As per coding guidelines: "Minimize React re-renders with useMemo, useCallback, and React.memo where appropriate; avoid unstable inline functions and objects".

Source: Coding guidelines

web/packages/agenta-chat/src/components/ComposerAttachments.tsx (1)

69-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the raw rgba() colors with Tailwind color utilities.

Lines 71, 96, and 98 hard-code rgba(0,0,0,0.6), rgba(0,0,0,0.4), and rgba(255,255,255,0.3). Tailwind opacity utilities give the same result inside the token system.

♻️ Proposed change
-                ? `absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border-0 bg-[rgba(0,0,0,0.6)] text-white transition-opacity ${persistent ? "" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`
+                ? `absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border-0 bg-black/60 text-white transition-opacity ${persistent ? "" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"}`
-                <div className="pointer-events-none absolute inset-0 rounded-lg bg-[rgba(0,0,0,0.4)]" />
+                <div className="pointer-events-none absolute inset-0 rounded-lg bg-black/40" />
                 <div className="pointer-events-none absolute inset-x-1.5 bottom-1.5 flex items-center gap-1.5">
-                    <div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-[rgba(255,255,255,0.3)]">
+                    <div className="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-white/30">

As per coding guidelines: "Consume theme colors through Ant Design semantic tokens, Tailwind color utilities, or supported var(--ag-color*) variables; do not use raw hex colors or --ag-c-* literals."

Also applies to: 96-98

Source: Coding guidelines

web/packages/agenta-chat/tests/unit/hooks/useAttachmentUploads.test.ts (1)

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

Add coverage for the hook itself.

Both tests exercise pure helpers. useAttachmentUploads holds the risky logic: the resume rule at Line 131, abort, retry scheduling, and unmount cleanup. @testing-library/react and jsdom are already devDependencies, so renderHook is available. A test that aborts an uploading row and then re-renders would pin the behavior I flagged at web/packages/agenta-chat/src/hooks/useAttachmentUploads.ts Lines 116-145.

Also move the abort assertion out of the setFiles callback so it runs unconditionally.

💚 Proposed change
         removeUploadFile("remove-me", abort, (updater) => {
-            expect(abort).toHaveBeenCalledWith("remove-me")
             nextFiles = updater(files)
         })
 
+        expect(abort).toHaveBeenCalledWith("remove-me")
         expect(nextFiles).toEqual([files[1]])

Do you want me to generate the renderHook tests for the abort and retry paths?

web/packages/agenta-chat/src/hooks/useAttachmentUploads.ts (1)

51-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move filesRef.current = files out of render.

Assigning filesRef.current in the component body mutates a ref while React renders, which is unsafe under concurrent rendering. React 19’s only render-phase ref write exception is initialization with ref.current === null, not later assignments. Move this assignment into an effect if run needs the latest non-empty files array.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: be24ff99-f5a1-492f-a385-5e25ed3d4cca

📥 Commits

Reviewing files that changed from the base of the PR and between 617face and 9a75085.

📒 Files selected for processing (45)
  • web/packages/agenta-chat/package.json
  • web/packages/agenta-chat/src/assets/attachmentRules.ts
  • web/packages/agenta-chat/src/assets/attachmentTransport.ts
  • web/packages/agenta-chat/src/assets/files.ts
  • web/packages/agenta-chat/src/assets/index.ts
  • web/packages/agenta-chat/src/assets/loadSession.ts
  • web/packages/agenta-chat/src/assets/motion.ts
  • web/packages/agenta-chat/src/assets/rewind.ts
  • web/packages/agenta-chat/src/assets/toolFormat.ts
  • web/packages/agenta-chat/src/assets/trace.ts
  • web/packages/agenta-chat/src/assets/transcriptToMessages.ts
  • web/packages/agenta-chat/src/components/ApprovalCard.tsx
  • web/packages/agenta-chat/src/components/AudioPlayer.tsx
  • web/packages/agenta-chat/src/components/ChatComposer.tsx
  • web/packages/agenta-chat/src/components/ComposerAttachments.tsx
  • web/packages/agenta-chat/src/components/index.ts
  • web/packages/agenta-chat/src/hooks/index.ts
  • web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts
  • web/packages/agenta-chat/src/hooks/useAgentConversation.ts
  • web/packages/agenta-chat/src/hooks/useAgentModelKeyStatus.ts
  • web/packages/agenta-chat/src/hooks/useAlwaysAllowTool.ts
  • web/packages/agenta-chat/src/hooks/useAttachmentUploads.ts
  • web/packages/agenta-chat/src/hooks/useComposerAttachments.ts
  • web/packages/agenta-chat/src/model/attachments.ts
  • web/packages/agenta-chat/src/model/error.ts
  • web/packages/agenta-chat/src/model/parts.ts
  • web/packages/agenta-chat/src/state/expandState.ts
  • web/packages/agenta-chat/src/state/sessionEphemera.ts
  • web/packages/agenta-chat/src/state/sessionMessages.ts
  • web/packages/agenta-chat/src/transport/AgentChatTransport.ts
  • web/packages/agenta-chat/tests/unit/assets/attachmentRules.test.ts
  • web/packages/agenta-chat/tests/unit/assets/attachmentTransport.test.ts
  • web/packages/agenta-chat/tests/unit/assets/files.test.ts
  • web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts
  • web/packages/agenta-chat/tests/unit/fixtures/approvalTurn.json
  • web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts
  • web/packages/agenta-chat/tests/unit/hooks/useAttachmentUploads.test.ts
  • web/packages/agenta-chat/tests/unit/hooks/useComposerAttachments.test.ts
  • web/packages/agenta-chat/tests/unit/model/attachments.test.ts
  • web/packages/agenta-chat/tests/unit/state/sessionMessages.test.ts
  • web/packages/agenta-playground/package.json
  • web/packages/agenta-playground/src/agentChat.ts
  • web/packages/agenta-playground/src/state/execution/agentRequest.ts
  • web/packages/agenta-playground/src/state/execution/executionHeaders.ts
  • web/packages/agenta-playground/src/state/execution/webWorkerIntegration.ts

Comment on lines +76 to +85
/** Human summary of what is accepted, e.g. "Images, audio, and documents". */
export const describeAccepted = (limits: AttachmentLimits): string => {
const nouns = limits.kinds.map((k) => KIND_NOUN[k])
if (nouns.length === 0) return "No attachments"
const sentence =
nouns.length === 1
? nouns[0]
: `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`
return sentence.charAt(0).toUpperCase() + sentence.slice(1)
}

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

Fix the two-item join in describeAccepted.

When two kinds are enabled, the output is "Images, and audio". A comma before "and" is wrong for a two-item list. ComposerAttachments renders this string in the empty state, so users see it.

✏️ Proposed fix
     const sentence =
         nouns.length === 1
             ? nouns[0]
-            : `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`
+            : nouns.length === 2
+              ? `${nouns[0]} and ${nouns[1]}`
+              : `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`
📝 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
/** Human summary of what is accepted, e.g. "Images, audio, and documents". */
export const describeAccepted = (limits: AttachmentLimits): string => {
const nouns = limits.kinds.map((k) => KIND_NOUN[k])
if (nouns.length === 0) return "No attachments"
const sentence =
nouns.length === 1
? nouns[0]
: `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`
return sentence.charAt(0).toUpperCase() + sentence.slice(1)
}
/** Human summary of what is accepted, e.g. "Images, audio, and documents". */
export const describeAccepted = (limits: AttachmentLimits): string => {
const nouns = limits.kinds.map((k) => KIND_NOUN[k])
if (nouns.length === 0) return "No attachments"
const sentence =
nouns.length === 1
? nouns[0]
: nouns.length === 2
? `${nouns[0]} and ${nouns[1]}`
: `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`
return sentence.charAt(0).toUpperCase() + sentence.slice(1)
}

Comment on lines +87 to +97
/** Build reference-carrying `file` parts for uploaded attachments (no inline bytes). */
export const attachmentRefsToParts = (refs: AttachmentRef[], sessionId: string): FileUIPart[] =>
refs.map((ref) => ({
type: "file",
mediaType: ref.mediaType,
filename: ref.filename,
url: attachmentContentUrl(sessionId, ref.attachmentId),
providerMetadata: {
agenta: {attachmentId: ref.attachmentId, size: ref.size},
},
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find consumers that read the file part url vs. the attachment id.
rg -n -C4 --type=ts --type=tsx 'attachmentIdForPart|attachmentContentUrl' web/packages web/oss 2>/dev/null
rg -n -C3 --type=ts --type=tsx "part\.url|\.url\b" web/packages/agenta-chat/src 2>/dev/null | rg -n 'file|attachment' -i

Repository: Agenta-AI/agenta

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -u

echo "tracked files matching files.ts:"
git ls-files | rg 'web/packages/agenta-chat/src/assets/files\.ts$' || true

echo
echo "list assets files:"
git ls-files 'web/packages/agenta-chat/src/assets/*' || true

echo
echo "files.ts outline:"
ast-grep outline web/packages/agenta-chat/src/assets/files.ts 2>/dev/null || true

echo
echo "files.ts relevant content:"
cat -n web/packages/agenta-chat/src/assets/files.ts | sed -n '1,160p'

echo
echo "search attachment refs in repo:"
rg -n -C3 'attachmentIdForPart|attachmentContentUrl|attachmentRefsToParts|providerMetadata.*agenta|attachmentId' web/packages/agenta-chat src web 2>/dev/null | head -n 300

Repository: Agenta-AI/agenta

Length of output: 35173


🏁 Script executed:

#!/bin/bash
set -u

echo "transcriptToMessages.ts relevant content:"
cat -n web/packages/agenta-chat/src/assets/transcriptToMessages.ts | sed -n '1,190p'

echo
echo "readers of attachmentIdForPart across tracked files:"
rg -n -C4 --glob '*.ts' --glob '*.tsx' 'attachmentIdForPart\(' . 2>/dev/null || true

echo
echo "readers of file part url in relevant package:"
rg -n -C3 --glob '*.ts' --glob '*.tsx' 'attachmentIdForPart|file\.url|\.url|FileUIPart|url:' web/packages/agenta-chat/src tests web 2>/dev/null | rg -n 'file|attachment|attachmentIdForPart|part\.url|\.url|providerMetadata' -C3 || true

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -u

echo "transcriptToMessages.ts top 170 lines:"
sed -n '1,170p' web/packages/agenta-chat/src/assets/transcriptToMessages.ts | cat -n

echo
echo "attachmentIdForPart call sites, tracked source only:"
git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' | xargs rg -n -C3 'attachmentIdForPart\(' 2>/dev/null || true

echo
echo "file part url access in agenta-chat tracked src/tests:"
git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' | xargs rg -n -C2 '\b(\.url|url:)' 2>/dev/null | rg -n -C2 'file|attachment|type.*file|FileUIPart|part\.url|\.url\b' || true

echo
echo "focused attachment content URL consumers, tracked source only:"
git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' | xargs rg -n -C3 'attachmentContentUrl|providerMetadata' 2>/dev/null | head -n 240 || true

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -u

echo "transcriptToMessages.ts lines 1-170 byte size:"
wc -c web/packages/agenta-chat/src/assets/transcriptToMessages.ts
sed -n '1,170p' web/packages/agenta-chat/src/assets/transcriptToMessages.ts | cat -n

echo
echo "attachmentIdForPart call sites, tracked agenta-chat source/tests only:"
git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' | xargs rg -n -C3 'attachmentIdForPart\(' 2>/dev/null || true

echo
echo "file part url access, tracked agenta-chat source/tests only:"
git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' | xargs rg -n -C2 'part\.url|file\.url|\.url\b|type:\s*"file"' 2>/dev/null | rg -n -C2 'file|attachment|attachmentIdForPart|ProviderMetadata|part\.url|file\.url' || true

Repository: Agenta-AI/agenta

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -u

python3 - <<'PY'
from pathlib import Path
files = [
    Path('web/packages/agenta-chat/src/assets/files.ts'),
    Path('web/packages/agenta-chat/src/assets/transcriptToMessages.ts'),
    Path('web/packages/agenta-chat/src/hooks/useComposerAttachments.ts'),
    Path('web/packages/agenta-chat/tests/unit/assets/files.test.ts'),
    Path('web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts'),
]
for p in files:
    print(f'\n--- {p} ({p.stat().st_size} bytes) ---')
    text = p.read_text()
    for needle in ['attachmentIdForPart', 'attachmentContentUrl', 'attachmentRefsToParts', '.url', 'part.url', 'file.url']:
        lines = [(i+1, lines[i].strip()) for i, lines in enumerate(text.splitlines(keepends=True)) if needle in lines[i]]
        if lines:
            print(f'NEEDLE {needle!r}:')
            for line, code in lines[:20]:
                print(f'{line}: {code}')
PY

echo
echo "Direct targeted grep for attachmentIdForPart source usages:"
for f in $(git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' 2>/dev/null); do
    if rg -q 'attachmentIdForPart' "$f"; then
        echo "--- $f ---"
        rg -n -C3 'attachmentIdForPart' "$f"
    fi
done

echo
echo "Direct targeted grep for file part url access in agenta-chat source/tests:"
for f in $(git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' 2>/dev/null); do
    if rg -q -E 'part\.(url|filename)|file\.(url|filename)|\btype:\s*"file"|attachmentIdForPart' "$f"; then
        echo "--- $f ---"
        rg -n -C2 -E 'part\.(url|filename)|file\.(url|filename)|\btype:\s*"file"|attachmentIdForPart' "$f" | head -n 80
    fi
done

Repository: Agenta-AI/agenta

Length of output: 511


🏁 Script executed:

#!/bin/bash
set -u

python3 - <<'PY'
from pathlib import Path
files = [
    Path('web/packages/agenta-chat/src/assets/files.ts'),
    Path('web/packages/agenta-chat/src/assets/transcriptToMessages.ts'),
    Path('web/packages/agenta-chat/src/hooks/useComposerAttachments.ts'),
    Path('web/packages/agenta-chat/tests/unit/assets/files.test.ts'),
    Path('web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts'),
]
for p in files:
    print(f'\n--- {p} ({p.stat().st_size} bytes) ---')
    text = p.read_text()
    for needle in ['attachmentIdForPart', 'attachmentContentUrl', 'attachmentRefsToParts', 'part.url', 'file.url', '.url']:
        hits = '\n'.join(f'{i+1}: {line.strip()}' for i, line in enumerate(text.splitlines()) if needle in line)
        if hits:
            print(f'NEEDLE {needle!r}:')
            print(hits)
        else:
            print(f'NEEDLE {needle!r}: no hits')
PY

echo
echo "Tracked agenta-chat source/tests lines mentioning attachmentIdForPart:"
for f in $(git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' 2>/dev/null); do
    if grep -q 'attachmentIdForPart' "$f"; then
        echo "### $f"
        grep -n -C 3 'attachmentIdForPart' "$f"
    fi
done

echo
echo "Tracked agenta-chat source/tests lines mentioning reference file part url:"
for f in $(git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' 2>/dev/null); do
    if grep -qE 'type:\s*"file"|attachmentIdForPart|part\.url|file\.url' "$f"; then
        echo "### $f"
        grep -n -C 2 -E 'type:\s*"file"|attachmentIdForPart|part\.url|file\.url' "$f" | head -n 120
    fi
done

Repository: Agenta-AI/agenta

Length of output: 2835


🏁 Script executed:

#!/bin/bash
set -u

echo "web/packages/agenta-chat/src/assets/transcriptToMessages.ts lines 120-152:"
sed -n '120,152p CARGO.toml web/packages/agenta-chat/src/assets/transcriptToMessages.ts' 2>/dev/null | sed 's#CARGO.toml#web/packages/agenta-chat/src/assets/transcriptToMessages.ts#' | cat -n

echo
echo "web/packages/agenta-chat/src/assets/transcriptToMessages.ts lines 260-300:"
sed -n '260,300p web/packages/agenta-chat/src/assets/transcriptToMessages.ts' | cat -n

echo
echo "Check transcript consumer lines for attachments/url in transcriptToMessages:"
sed -n '1,180p web/packages/agenta-chat/src/assets/transcriptToMessages.ts' | cat -n

echo
echo "Test expectation around transcript file part:"
sed -n '700,725p web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts' | cat -n

echo
echo "Focused script: find transcript consumers mentioning fileUrl/FileUrl/url/content for attachments"
python3 - <<'PY'
from pathlib import Path
p = Path('web/packages/agenta-chat/src/assets/transcriptToMessages.ts')
text = p.read_text()
terms = 'fileUrl', 'FileUrl', 'content', 'attachments', 'attachmentId'
for i,line in enumerate(text.splitlines(), 1):
    if any(t in line for t in terms):
        print(f'{i}: {line}')
PY

echo
echo "Find direct consumers of transcriptsToMessages or transcriptToMessages in source/tests:"
for f in $(git ls-files 'web/packages/agenta-chat/src/**/*.{ts,tsx}' 'web/packages/agenta-chat/tests/**/*.{ts,tsx}' 2>/dev/null); do
    if grep -q -E 'transcriptToMessages|transcriptsToMessages|messagesFrom' "$f"; then
        echo "### $f"
        grep -n -C 3 -E 'transcriptToMessages|transcriptsToMessages|messagesFrom' "$f" | head -n 120
    fi
done

Repository: Agenta-AI/agenta

Length of output: 1519


Use attachment metadata when reconstructing persisted transcript file parts.

attachmentRefsToParts stores the attachmentId in providerMetadata.agenta, but transcript parsing still calls attachmentContentUrl(sessionId, attachmentId), which bakes the current API host into the persisted URL. When the API host changes, the stored part can point to the old domain. Rebuild the content URL from the current runtime API host and session id using the stored attachmentId instead of trusting the stored file.url.

onClick={() =>
onDenyAll(approvals.map((a) => a.approvalId))
}
className="justify-start text-colorError hover:bg-[var(--ant-color-error-bg)]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a supported theme variable for the error background.

Line 351 uses the raw --ant-color-error-bg literal. The guidelines allow Ant Design semantic tokens, Tailwind color utilities, or var(--ag-color*) variables only. Replace it with a Tailwind token utility, for example hover:bg-colorErrorBg, and verify the hover state in light and dark themes.

As per coding guidelines: "Consume theme colors through Ant Design semantic tokens, Tailwind color utilities, or supported var(--ag-color*) variables; do not use raw hex colors or --ag-c-* literals."

Source: Coding guidelines

Comment on lines +34 to +40
useEffect(() => {
const el = audioRef.current
if (!el) return
// A new source voids any probe from the previous one. Without this reset, a src swap mid-probe
// leaves probingRef stuck true, which gates off onTimeUpdate below and freezes the timer.
probingRef.current = false

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

Reset duration and current when src changes.

The effect resets probingRef but keeps the duration and current state of the previous source. Until loadedmetadata fires for the new source, the UI shows the old elapsed time and the old total, and the progress bar renders at the old fill.

🐛 Proposed fix
         probingRef.current = false
+        setCurrent(0)
+        setDuration(0)
📝 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
useEffect(() => {
const el = audioRef.current
if (!el) return
// A new source voids any probe from the previous one. Without this reset, a src swap mid-probe
// leaves probingRef stuck true, which gates off onTimeUpdate below and freezes the timer.
probingRef.current = false
useEffect(() => {
const el = audioRef.current
if (!el) return
// A new source voids any probe from the previous one. Without this reset, a src swap mid-probe
// leaves probingRef stuck true, which gates off onTimeUpdate below and freezes the timer.
probingRef.current = false
setCurrent(0)
setDuration(0)

Comment on lines +145 to +148
<div
role={onClick ? "button" : undefined}
onClick={onClick}
className={`flex ${TILE} items-center gap-2 rounded-lg border border-solid border-colorBorderSecondary bg-colorFillQuaternary px-2 ${onClick ? "cursor-pointer hover:border-colorBorder" : ""} ${className ?? ""}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

role="button" elements are not keyboard operable.

The Chip wrapper at Line 146 and the image tile at Line 334 take role="button" and an onClick, but neither sets tabIndex nor handles keyboard activation. A keyboard user cannot open an attachment in the viewer. The Remove control is a real <button>, so only the view action is unreachable.

🐛 Proposed fix
     <div
         role={onClick ? "button" : undefined}
+        tabIndex={onClick ? 0 : undefined}
         onClick={onClick}
+        onKeyDown={
+            onClick
+                ? (e) => {
+                      if (e.key === "Enter" || e.key === " ") {
+                          e.preventDefault()
+                          onClick()
+                      }
+                  }
+                : undefined
+        }
         className={`flex ${TILE} items-center gap-2 rounded-lg border border-solid border-colorBorderSecondary bg-colorFillQuaternary px-2 ${onClick ? "cursor-pointer hover:border-colorBorder" : ""} ${className ?? ""}`}
     >

Apply the same tabIndex and onKeyDown pair to the image tile at Line 333, and add a visible focus ring to both so the focus position is clear in light and dark themes.

Also applies to: 333-339

Comment on lines +116 to +145
const abort = useCallback((uid: string) => {
queued.current.delete(uid)
retryAt.current.delete(uid)
retryable.current.delete(uid)
clearTimeout(retryTimers.current.get(uid))
retryTimers.current.delete(uid)
const controller = controllers.current.get(uid)
controllers.current.delete(uid)
controller?.abort()
}, [])
const enqueue = useCallback((uids: string[]) => {
uids.forEach((uid) => queued.current.add(uid))
}, [])
useEffect(() => {
// A remounted tray resumes in-flight entries with the same uid/idempotency key.
for (const file of files) {
if (
file.status === "uploading" &&
file.originFileObj &&
!controllers.current.has(file.uid)
) {
queued.current.add(file.uid)
}
}
for (const uid of queued.current) {
if (!files.some((file) => file.uid === uid)) continue
queued.current.delete(uid)
run(uid)
}
}, [files, run])

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 | 🟠 Major | ⚡ Quick win

abort does not stop an upload; the resume effect restarts it.

abort deletes the controller and cancels the request, but it never changes the row's status. The row stays status: "uploading" with originFileObj set. On the next files change the effect at Line 131 finds a row that is uploading and has no controller, re-queues it, and run starts the upload again. Only removeUploadFile stops an upload for good, because it also removes the row.

abort is part of the exported AttachmentUploads contract and reaches hosts through useComposerAttachments().uploads and ChatComposer. A host that cancels one upload without removing the chip will see it resume.

Mark aborted rows so the resume rule skips them.

🐛 Proposed fix
     const abort = useCallback((uid: string) => {
         queued.current.delete(uid)
         retryAt.current.delete(uid)
         retryable.current.delete(uid)
         clearTimeout(retryTimers.current.get(uid))
         retryTimers.current.delete(uid)
         const controller = controllers.current.get(uid)
         controllers.current.delete(uid)
         controller?.abort()
+        aborted.current.add(uid)
     }, [])
     useEffect(() => {
         // A remounted tray resumes in-flight entries with the same uid/idempotency key.
         for (const file of files) {
             if (
                 file.status === "uploading" &&
                 file.originFileObj &&
+                !aborted.current.has(file.uid) &&
                 !controllers.current.has(file.uid)
             ) {
                 queued.current.add(file.uid)
             }
         }

Add const aborted = useRef(new Set<string>()) beside the other refs, and aborted.current.delete(uid) at the start of run so a retry clears the flag.

Run the following script to find hosts that call abort without removing the row:

#!/bin/bash
# Locate uploads.abort call sites outside removeUploadFile.
rg -nP --type=ts --type=tsx -C4 '\buploads\.abort\s*\(|\babort\s*\(\s*uid' web/packages -g '!**/node_modules/**'
rg -nP --type=ts --type=tsx -C4 '\bremoveUploadFile\s*\(' web/packages -g '!**/node_modules/**'

Comment on lines 60 to 68
// Restored from the per-session store on remount (route re-entry, tab close/reopen) —
// pending attachments survive alongside the composer draft. Rejections stay transient.
const [files, setFiles] = useState<PendingAttachment[]>(() =>
sessionId ? (attachmentsBySession.get(sessionId) ?? []) : [],
const [files, setFiles] = useState<StagedFile[]>(
() => (attachmentsBySession.get(sessionId) as StagedFile[] | undefined) ?? [],
)
// `sessionId` is a prop. Today's only caller mounts one instance per session, but a caller
// that swapped it in place would carry the old session's staged files into the new one, and
// the mirror effect below would then write them under the new key. Re-seed during render so
// that effect never sees a mismatched pair.
const [seededFor, setSeededFor] = useState(sessionId)
if (sessionId !== seededFor) {
setSeededFor(sessionId)
setFiles(sessionId ? (attachmentsBySession.get(sessionId) ?? []) : [])
}
useEffect(() => {
if (!sessionId) return
if (files.length > 0) attachmentsBySession.set(sessionId, files)
else attachmentsBySession.delete(sessionId)
}, [files, sessionId])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

A sessionId change on a mounted hook moves staged files into the new session.

The useState initializer at Line 62 runs only on mount. If sessionId changes while the hook stays mounted, files keeps the previous session's rows, and the effect at Line 65 writes those rows under the new sessionId. The previous session's map entry is also orphaned. Because attachmentUploader closes over sessionId, a retry then uploads a file staged for session A into session B.

Nothing in the hook enforces the remount that the comment assumes. Either resynchronize on sessionId change, or require hosts to key the component by session.

🐛 Proposed fix — resynchronize on session change
     const [files, setFiles] = useState<StagedFile[]>(
         () => (attachmentsBySession.get(sessionId) as StagedFile[] | undefined) ?? [],
     )
+    const loadedSession = useRef(sessionId)
+    if (loadedSession.current !== sessionId) {
+        loadedSession.current = sessionId
+        setFiles((attachmentsBySession.get(sessionId) as StagedFile[] | undefined) ?? [])
+    }
     useEffect(() => {
         if (files.length > 0) attachmentsBySession.set(sessionId, files)
         else attachmentsBySession.delete(sessionId)
     }, [files, sessionId])

Run the following script to check whether hosts remount the composer per session:

#!/bin/bash
# Find useComposerAttachments call sites and check for a session-based React key on the owning component.
rg -nP --type=ts --type=tsx -C10 'useComposerAttachments\s*\(' web -g '!**/node_modules/**'
rg -nP --type=tsx -C3 '<ChatComposer\b' web -g '!**/node_modules/**'

Comment on lines +141 to +169
const uploadExtraFiles = async (extraFiles: File[]): Promise<StagedFile[] | null> => {
const staged = extraFiles.map((file) => toUploadFile(file, uploadsEnabled))
const results = await Promise.allSettled(
staged.map(async (entry) => {
const response = await attachmentUploader(entry.originFileObj as File, {
uid: entry.uid,
onProgress: () => undefined,
signal: new AbortController().signal,
})
return {...entry, status: "done" as const, percent: 100, response}
}),
)
if (results.every((result) => result.status === "fulfilled")) {
return results.map((result) => (result as PromiseFulfilledResult<StagedFile>).value)
}
const settledEntries = results.map((result, index) =>
result.status === "fulfilled"
? result.value
: {
...staged[index],
status: "error" as const,
error:
result.reason instanceof Error ? result.reason.message : "Upload failed",
},
)
setFiles((prev) => [...prev, ...settledEntries])
setAttachmentsOpen(true)
return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

uploadExtraFiles creates an AbortController it immediately discards.

Line 148 passes new AbortController().signal, and the controller is never retained. These uploads cannot be cancelled. They keep running after the composer unmounts or the user switches session, because useAttachmentUploads holds no controller for these uids either — run is bypassed.

Hold one controller per entry and abort them on unmount.

🐛 Proposed fix
+    // Controllers for send-time uploads that never entered the tray.
+    const extraControllers = useRef(new Map<string, AbortController>())
+    useEffect(
+        () => () => {
+            extraControllers.current.forEach((controller) => controller.abort())
+            extraControllers.current.clear()
+        },
+        [],
+    )
+
     const uploadExtraFiles = async (extraFiles: File[]): Promise<StagedFile[] | null> => {
         const staged = extraFiles.map((file) => toUploadFile(file, uploadsEnabled))
         const results = await Promise.allSettled(
             staged.map(async (entry) => {
-                const response = await attachmentUploader(entry.originFileObj as File, {
-                    uid: entry.uid,
-                    onProgress: () => undefined,
-                    signal: new AbortController().signal,
-                })
-                return {...entry, status: "done" as const, percent: 100, response}
+                const controller = new AbortController()
+                extraControllers.current.set(entry.uid, controller)
+                try {
+                    const response = await attachmentUploader(entry.originFileObj as File, {
+                        uid: entry.uid,
+                        onProgress: () => undefined,
+                        signal: controller.signal,
+                    })
+                    return {...entry, status: "done" as const, percent: 100, response}
+                } finally {
+                    extraControllers.current.delete(entry.uid)
+                }
             }),
         )

Separately, this path uploads even when uploadsEnabled is false, while toUploadFile marks the entries done for that same flag. Confirm that the inline-only rollout state is meant to allow send-time uploads.

📝 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
const uploadExtraFiles = async (extraFiles: File[]): Promise<StagedFile[] | null> => {
const staged = extraFiles.map((file) => toUploadFile(file, uploadsEnabled))
const results = await Promise.allSettled(
staged.map(async (entry) => {
const response = await attachmentUploader(entry.originFileObj as File, {
uid: entry.uid,
onProgress: () => undefined,
signal: new AbortController().signal,
})
return {...entry, status: "done" as const, percent: 100, response}
}),
)
if (results.every((result) => result.status === "fulfilled")) {
return results.map((result) => (result as PromiseFulfilledResult<StagedFile>).value)
}
const settledEntries = results.map((result, index) =>
result.status === "fulfilled"
? result.value
: {
...staged[index],
status: "error" as const,
error:
result.reason instanceof Error ? result.reason.message : "Upload failed",
},
)
setFiles((prev) => [...prev, ...settledEntries])
setAttachmentsOpen(true)
return null
}
// Controllers for send-time uploads that never entered the tray.
const extraControllers = useRef(new Map<string, AbortController>())
useEffect(
() => () => {
extraControllers.current.forEach((controller) => controller.abort())
extraControllers.current.clear()
},
[],
)
const uploadExtraFiles = async (extraFiles: File[]): Promise<StagedFile[] | null> => {
const staged = extraFiles.map((file) => toUploadFile(file, uploadsEnabled))
const results = await Promise.allSettled(
staged.map(async (entry) => {
const controller = new AbortController()
extraControllers.current.set(entry.uid, controller)
try {
const response = await attachmentUploader(entry.originFileObj as File, {
uid: entry.uid,
onProgress: () => undefined,
signal: controller.signal,
})
return {...entry, status: "done" as const, percent: 100, response}
} finally {
extraControllers.current.delete(entry.uid)
}
}),
)
if (results.every((result) => result.status === "fulfilled")) {
return results.map((result) => (result as PromiseFulfilledResult<StagedFile>).value)
}
const settledEntries = results.map((result, index) =>
result.status === "fulfilled"
? result.value
: {
...staged[index],
status: "error" as const,
error:
result.reason instanceof Error ? result.reason.message : "Upload failed",
},
)
setFiles((prev) => [...prev, ...settledEntries])
setAttachmentsOpen(true)
return null
}

Comment on lines +33 to +37
* are globally unique, so this store has no scope dimension.
* v2: caches written by the pre-fix mapper hold duplicated approval parts; the key bump forces
* one re-sync from records (the watermark otherwise keeps the stale copy authoritative). */
export const sessionMessagesAtom = atomWithStorage<Record<string, UIMessage[]>>(
"agenta:agent-chat:messages",
"agenta:agent-chat:messages:v2",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect: migration or cleanup for pre-v2 keys and deletion coverage.
rg -n -C 8 \
  'agenta:agent-chat:(messages|record-counts)(:v2)?|dropSessionMessagesAtom|removeItem|migrat' \
  web/packages

Repository: Agenta-AI/agenta

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
fd -a 'sessionMessages|useAgentConversation' web/packages/agenta-chat/src | sed 's#^\./##'

echo
echo "== sessionMessages outline =="
ast-grep outline web/packages/agenta-chat/src/state/sessionMessages.ts --view compact || true

echo
echo "== sessionMessages relevant lines =="
nl -ba web/packages/agenta-chat/src/state/sessionMessages.ts | sed -n '1,140p'

echo
echo "== useAgentConversation relevant lines =="
nl -ba web/packages/agenta-chat/src/hooks/useAgentConversation.ts | sed -n '120,330p'

echo
echo "== focused key references in chat package =="
rg -n -C 5 'agenta:agent-chat:messages|record-counts|dropSessionMessagesAtom|removeItem|localStorage|setItem|getItem|re.*sync|sync' web/packages/agenta-chat/src

Repository: Agenta-AI/agenta

Length of output: 588


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== sessionMessages.ts =="
awk '{printf "%6d: %s\n", NR, $0}' web/packages/agenta-chat/src/state/sessionMessages.ts | sed -n '1,180p'

echo
echo "== useAgentConversation.ts relevant section =="
awk '{printf "%6d: %s\n", NR, $0}' web/packages/agenta-chat/src/hooks/useAgentConversation.ts | sed -n '120,340p'

echo
echo "== focused key references in chat package =="
rg -n -C 5 'agenta:agent-chat:messages|record-counts|dropSessionMessagesAtom|removeItem|localStorage|setItem|getItem|re.*sync|sync' web/packages/agenta-chat/src

echo
echo "== all tracked references to pre-v2 keys =="
rg -n 'agenta:agent-chat:(messages|record-counts)(?!:v2)|dropSessionMessagesAtom' web

echo
echo "== diff stat/name-only for context =="
git diff --stat || true
git diff --name-only || true

Repository: Agenta-AI/agenta

Length of output: 50372


Clean up the pre-v2 transcript keys.

dropSessionMessagesAtom only deletes entries in the :v2 stores, and the hydration/revalidation path reads sessionMessagesAtom, so existing agenta:agent-chat:messages / agenta:agent-chat:record-counts data can remain in localStorage. Add a one-time migration or delete those legacy keys on successful v2 re-sync.

Comment on lines +245 to +249
const delay = Math.min(SMOOTH_BASE_MS, SMOOTH_CHUNK_BUDGET_MS / pieces.length)
for (const piece of pieces) {
controller.enqueue({...(value as object), delta: piece} as AnyChunk)
if (delay >= 1) await sleep(delay)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Keep pacing and backpressure for large deltas.

For more than 1,200 pieces, delay is less than one. Line 248 then emits every piece in one pull() call. A large server delta can create thousands of chunks without yielding or honoring controller.desiredSize.

Keep pending pieces across pulls, or schedule bounded batches. Do not disable yielding when the chunk exceeds the time budget.

…stops churning previews

Four defects in @agenta/chat's composer attachments, all in the same subsystem.

`uploads.abort(uid)` only paused an upload. It released the controller but left the
row reading "uploading" — precisely the shape the remount-resume rule restarts — so
the next `files` identity relaunched the request, which mid-upload is many times a
second. It now marks the uid cancelled for this mount AND settles the row as a
retryable error, while an unmount-abort (a different code path) still resumes.

`ComposerAttachments` rebuilt every preview object URL whenever the array identity
changed, so an upload's progress ticks revoked and re-minted every URL continuously:
thumbnails flickered, audio playback restarted, and a decode against a just-revoked
URL latched the broken-image fallback permanently (nothing ever cleared it). URLs are
now keyed by uid, minted once, and released when the row or its blob goes away.

`uploadExtraFiles` built an AbortController and threw it away, so those uploads could
never be cancelled. They are held and aborted on unmount now.

A `sessionId` change on a mounted hook wrote session A's staged rows under session B —
the state initializer runs only on mount, and nothing makes hosts remount per session
(the chat workspace keeps tab panes mounted). The rows now move back under their own
session before the incoming one is adopted.

Also makes the tile view affordance a real button instead of a `role="button"` div
with no tabIndex and no key handling — as a sibling of the Remove button, not a
wrapper around it.
…place

`useAgentConversation` carried two near-identical `adopt` closures plus a third
inline copy in the hydration path, and all three reconciled by MESSAGE COUNT alone —
the comment said so outright. `transcriptToMessages` folds a paused turn into its
resume and only closes a message on `done`, so a turn that completes IN PLACE (an
approval resolving into the same assistant message) leaves the count unchanged and
the finished server transcript was skipped. `transcript.recordCount` was persisted on
every path and never compared.

The three copies collapse into one `adoptServerTranscript` that runs the shared
`shouldAdoptServerTranscript` rule: the record watermark is the trigger, the message
count only a floor. The copies had already drifted — one cleared the
history-unavailable notice and the other didn't; adopting real history settles that
question, so it clears everywhere. The watermark is seeded from the persisted store,
cleared when a live turn supersedes the transcript, and filed with the persist.

Two smaller data-integrity fixes alongside:

`filePartContentUrl` rebuilds an attachment's content URL from the current runtime API
host plus the stored attachment id, rather than trusting a `url` that was baked when
the part was persisted — a host change otherwise leaves stored parts pointing at the
old domain. Parts with no attachment id (inline data URLs, anything predating the
metadata) keep their stored URL.

The pre-`:v2` localStorage stores are dropped once at load. Nothing reads them, but
`dropSessionMessagesAtom` only ever deleted the `:v2` keys, so they sat there taking
quota from the stores that are used.
…age fixes

`@agenta/chat` is consumed by the mobile app, which does not load antd's CSS
variables, so the three raw `var(--ant-color-error-bg)` literals resolved to nothing
there — on top of violating the repo rule against raw `--ant-*` literals. They become
the `colorErrorBg` Tailwind token the rest of the package already uses.

The smooth-stream pacing collapsed on large deltas: above ~1,200 pieces the per-piece
delay drops below a millisecond, the sleep was skipped entirely, and the whole delta
was flushed from a single `pull()` — thousands of enqueues with no yield and no
further look at the consumer. Pieces now carry across `pull()` calls and a pull stops
once the consumer is full; the cadence for normal-sized deltas is unchanged. The new
test pins what actually matters: the text still reassembles byte-identically, in order.

`describeAccepted` wrote "Images, and audio" — a two-item list takes a bare "and".
It renders in the composer's empty state.

`AudioPlayer` left the previous source's duration and elapsed time on screen until
`loadedmetadata` fired for the new one; both reset with the src now.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant