Skip to content

Conversation

@yujonglee
Copy link
Contributor

No description provided.

@netlify
Copy link

netlify bot commented Nov 21, 2025

Deploy Preview for hyprnote ready!

Name Link
🔨 Latest commit f1a9c39
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/691fe4aa685cbd00089a61c3
😎 Deploy Preview https://deploy-preview-1760--hyprnote.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link

coderabbitai bot commented Nov 21, 2025

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive attachment system enabling file uploads for chat messages and enhanced note sessions. It adds a generic, reusable attachment storage layer with manifest tracking and concurrency control, integrates file input into message and editor UIs, and extends the AI enhancement workflow to process attached files as model inputs.

Changes

Cohort / File(s) Summary
Core attachment storage & dependencies
apps/desktop/package.json, apps/desktop/src/shared/attachments/constants.ts, apps/desktop/src/shared/attachments/storage.ts
New async-mutex dependency added for concurrency control. New shared module createAttachmentStorage<T>() factory for generic attachment persistence with manifest management, file I/O, and data URL conversion. Exports ManifestEntry, ManifestCorruptionError, and StorageConfig. Adds ATTACHMENT_SIZE_LIMIT constant (10 MB).
Chat attachment storage
apps/desktop/src/components/chat/attachments/storage.ts
Chat-scoped attachment storage with public API: loadChatAttachments, saveChatAttachment, removeChatAttachment, removeChatGroupAttachments, readChatAttachmentAsDataURL. Exposes PersistedChatAttachment type.
Chat attachment types & message resolution
apps/desktop/src/chat/types.ts, apps/desktop/src/chat/resolve-attachments.ts
New ChatFileReferencePart and ChatDataParts types added to support file references in messages. New resolveChatFileReferences() function converts data-chat-file parts to file parts with resolved data URLs.
Chat transport & session integration
apps/desktop/src/chat/transport.ts, apps/desktop/src/components/chat/session.tsx
CustomChatTransport constructor now accepts optional chatGroupId parameter; messages are pre-processed via resolveChatFileReferences before sending to model. useTransport hook updated to accept and forward chatGroupId.
Chat message input & display
apps/desktop/src/components/chat/input.tsx, apps/desktop/src/components/chat/message/normal.tsx, apps/desktop/src/components/chat/message/types.ts
Chat input component extended with file attachment support, attachment state tracking, and size/capability validation. Message rendering updated with FileAttachment and ChatFileAttachment components. Part type generics updated to include ChatDataParts.
Chat message view & handler
apps/desktop/src/components/chat/view.tsx
Added ensurePersistedAttachmentParts() helper to persist file attachments and convert to data-chat-file references. Integration of saveChatAttachment and attachment handling in message send flow. New deriveChatTitle() helper.
Session attachment storage
apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts, apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx
Session-scoped attachment storage with loadSessionAttachments, saveSessionAttachment, removeSessionAttachment functions. New Attachments UI component for rendering attachment cards with remove callbacks and loading states.
Raw & enhanced editor extensions
apps/desktop/src/components/main/body/sessions/note-input/raw.tsx, apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx, apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx
Both raw and enhanced editors extended with optional onFilesAdded and onContentChange callbacks. File drop/paste handling configured via FileHandlerConfig. Content change events surfaced to parent via callbacks.
Editor header & tab navigation
apps/desktop/src/components/main/body/sessions/note-input/header.tsx
Header extended with new attachments tab and file upload input. useEditorTabs signature updated to accept shouldShowAttachments flag and return attachments tab variant. Tab labeling and conditional rendering updated.
Note input main component
apps/desktop/src/components/main/body/sessions/note-input/index.tsx
New useSessionAttachments hook manages attachment lifecycle (loading, uploading, removal, orphan cleanup). New EditorViewContainer and useMountedEditorView for rendering appropriate editor variant. Attachment reference collection, deduplication, and content pruning. Exports public Attachment type.
Tauri permissions
apps/desktop/src-tauri/capabilities/default.json
New fs:allow-remove capability added for removing files in $DATA/hyprnote/**/* path.
AI task attachment integration
apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts, apps/desktop/src/store/zustand/ai-task/task-configs/enhance-workflow.ts, apps/desktop/src/store/zustand/ai-task/task-configs/index.ts, apps/desktop/src/store/zustand/ai-task/shared/resolve-attachments.ts
Enhance task args extended with attachments field. New resolveSessionAttachments() function converts attachment references to FilePart array with data URLs. Enhance workflow updated to pass attachment parts to model along with prompt.
Editor view schema
apps/desktop/src/store/zustand/tabs/schema.ts
New attachments variant added to editorViewSchema discriminated union with corresponding type guard isAttachmentsView().

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ChatInput as Chat Input<br/>(UI)
    participant Storage as Chat Attachment<br/>Storage
    participant Transport as Chat Transport
    participant Resolver as Attachment<br/>Resolver
    participant Model as Language<br/>Model

    User->>ChatInput: Select file, type message, send
    ChatInput->>Storage: saveChatAttachment(groupId, file)
    Storage-->>ChatInput: PersistedChatAttachment
    ChatInput->>ChatInput: Build message with<br/>data-chat-file parts
    ChatInput->>Transport: send(message, attachments)
    
    Transport->>Resolver: resolveChatFileReferences(message, groupId)
    Resolver->>Storage: readChatAttachmentAsDataURL(groupId, id)
    Storage-->>Resolver: data URL
    Resolver->>Resolver: Convert data-chat-file<br/>to file part with data URL
    Resolver-->>Transport: Resolved message
    
    Transport->>Model: stream(resolvedMessage)
    Model-->>Transport: Streamed response
    Transport-->>ChatInput: Display response
Loading
sequenceDiagram
    participant User
    participant Editor as Editor<br/>(raw/enhanced)
    participant Header as Header/Tabs
    participant Storage as Session Attachment<br/>Storage
    participant NoteInput as Note Input<br/>Manager
    participant EditorContent as Editor<br/>Content

    User->>Header: Click Attachments tab
    Header->>NoteInput: Switch to attachments view
    User->>Header: Upload file
    Header->>Header: File picker → select file
    Header->>NoteInput: onUploadAttachments([file])
    
    NoteInput->>Storage: saveSessionAttachment(sessionId, file)
    Storage-->>NoteInput: PersistedAttachment
    NoteInput->>NoteInput: Track in state, generate objectUrl
    NoteInput-->>Header: Render in attachments list
    
    User->>EditorContent: Switch back to editor tab
    User->>Editor: Type/paste content<br/>(with attachment reference)
    Editor->>NoteInput: onContentChange(content)
    NoteInput->>NoteInput: Collect attachment references<br/>from editor
    NoteInput->>NoteInput: Sync references with state<br/>Prune orphans
Loading
sequenceDiagram
    participant User
    participant Enhance as Enhance Task<br/>Workflow
    participant AttachmentResolver as Resolve Session<br/>Attachments
    participant Model as Language<br/>Model

    User->>Enhance: Trigger enhance on session<br/>with attachments
    Enhance->>Enhance: Load session attachments<br/>via transformArgs
    Enhance->>AttachmentResolver: resolveSessionAttachments(sessionId, attachments)
    
    loop For each image attachment
        AttachmentResolver->>AttachmentResolver: Check size & MIME type
        AttachmentResolver->>AttachmentResolver: Read file, convert to<br/>base64 data URL
        AttachmentResolver-->>Enhance: FilePart[] with data URLs
    end
    
    Enhance->>Enhance: Build messages array:<br/>enhancedPrompt + attachmentParts
    Enhance->>Model: streamText(messages)
    Model-->>Enhance: Generated enhancement
    Enhance-->>User: Display result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Areas requiring extra attention:

  • Generic attachment storage (apps/desktop/src/shared/attachments/storage.ts): Review concurrency control patterns (async-mutex usage), manifest integrity handling, file-system safety, and error propagation. Verify serialization/deserialization logic and edge cases around corrupted manifests.
  • Attachment lifecycle in note-input (apps/desktop/src/components/main/body/sessions/note-input/index.tsx): Complex state management spanning useSessionAttachments hook, optimistic UI updates, orphan cleanup logic, and cross-view reference collection. Verify cleanup correctness to prevent dangling files.
  • Chat attachment resolution pipeline (apps/desktop/src/chat/resolve-attachments.ts, apps/desktop/src/chat/view.tsx): Verify that data-chat-file parts are consistently converted to file parts with resolved URLs and that error fallback behavior (retaining original part on resolution failure) is sound.
  • Type consistency across layers: Confirm ChatFileReferencePart, PersistedChatAttachment, PersistedAttachment, and Attachment are correctly threaded through UI, storage, and task layers without type mismatches.
  • AI task integration (enhance-transform.ts, enhance-workflow.ts): Verify attachment loading and resolution occur at the correct point in the transform/workflow chain and that errors are handled gracefully without blocking enhancement.

Possibly related PRs

  • setup file handler extension & tiptap styling #1748: Modifies TipTap editor file-handling surface by adding FileHandlerConfig and onFilesAdded prop—directly related to the raw/enhanced editor file drop/paste integration in this PR.
  • Overall redesign final 0930 #1514: Adds tabbed editor UI and evolves header/tab types—related to the new attachments tab variant and editor view schema updates in this PR.
  • Support multi-enhanced-tab #1685: Modifies enhanced editor and related note-input components—overlaps with enhanced editor callback additions and streaming/state management in this PR.

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning No pull request description was provided by the author, making it impossible to assess whether any description relates to the changeset. Add a description explaining the purpose of image attachment support, which features were added, and any relevant implementation details or testing notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'image-attachment-support' accurately describes the primary change: adding support for image attachments throughout the codebase, including storage, UI components, and chat functionality.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch yl-branch-27

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

@yujonglee yujonglee marked this pull request as draft November 21, 2025 04:04
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (11)
apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx (2)

7-94: Consider keyboard navigation for attachment cards.

The AttachmentCard component renders clickable elements (remove button, link) but the card itself is not keyboard-navigable. For better accessibility, consider making the entire card focusable when it represents an interactive element (e.g., viewable attachment).

For example, wrap non-link attachments in a button or add tabIndex={0} with appropriate keyboard handlers if cards should be selectable or viewable:

<button
  type="button"
  onClick={() => onView?.(attachment.id)}
  className="flex flex-col gap-3 p-4 border border-neutral-200 rounded-lg bg-white hover:bg-neutral-50 transition-colors relative text-left w-full"
>
  {/* existing content */}
</button>

115-115: Improve empty state message for accessibility.

The message "Use the + icon above to add one" assumes visual context and may not be helpful for screen reader users or if the UI changes. Consider a more descriptive message.

-<p>No attachments yet. Use the + icon above to add one.</p>
+<p>No attachments yet. Add files using the attachment button.</p>
apps/desktop/src/store/zustand/ai-task/task-configs/enhance-workflow.ts (1)

17-17: Attachment-aware prompt construction looks good; avoid Array<any> for messages

The new attachmentParts resolution and inclusion in the single user message content correctly reuses resolveSessionAttachments and ensures only supported attachments reach the model. The overall control flow and abort handling remain intact.

To keep type-safety, consider replacing Array<any> for messages with a concrete type (e.g., a small local type EnhanceMessage = { role: "user"; content: typeof attachmentParts[number][] | ... }) so future changes to attachment parts or content shape are checked by TypeScript instead of being hidden behind any.

Also applies to: 162-166, 186-200

apps/desktop/src/components/chat/message/normal.tsx (1)

152-214: Factor out shared attachment UI and harden link attributes

FileAttachment and ChatFileAttachment are almost identical aside from accessing part vs part.data. You could reduce duplication by extracting a small AttachmentView that takes { url, mediaType, filename }, and have both components delegate to it.

Also, since links open in a new tab, consider using rel="noreferrer noopener" to future‑proof against window.opener issues if non‑data/blob URLs are ever introduced.

apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts (1)

42-61: Align attachment typing with shared resolver to avoid drift

The new attachments plumbing in transformArgs looks consistent with how resolveSessionAttachments expects AttachmentReference (id, fileName, mimeType, size, fileUrl), and the failure‑to‑load behavior (log + []) is a good safety net.

The one concern is that this attachment shape is now effectively duplicated here via the inline mapping in getSessionAttachments, while AttachmentReference is separately defined in shared/resolve-attachments.ts. If either side adds/removes fields later, it’s easy for them to go out of sync without a type error.

Consider exporting a shared AttachmentReference type from a common module (or from shared/resolve-attachments.ts) and reusing it here so the compiler enforces schema consistency between storage, transform, and resolver.

Also applies to: 384-398

apps/desktop/src/chat/resolve-attachments.ts (1)

6-52: Clarify contract when chatGroupId is missing to avoid leaking data-chat-file parts

The transformation from data-chat-filefile (via readChatAttachmentAsDataURL) and the passthrough of blob: file parts both look reasonable.

However, when chatGroupId is falsy you currently:

if (part.type === "data-chat-file") {
  if (!chatGroupId) {
    return part; // remains a non-standard part type
  }
  // ...
}

If resolveChatFileReferences is ever called without a chatGroupId while messages still contain data-chat-file parts, those parts will flow downstream unchanged, which may not be understood by consumers expecting only the standard UI/FileUIPart variants.

To make this behavior more explicit/robust, consider either:

  • Short‑circuiting at the top: when !chatGroupId, just return messages; (if the invariant is that this helper is only meaningfully used when a group id exists), and/or
  • Emitting a warning if !chatGroupId but a data-chat-file part is encountered, so it’s easier to detect incorrect call sites.

This keeps runtime behavior the same today but guards against future misuse of the helper.

apps/desktop/src/components/chat/view.tsx (1)

71-116: Make the partsattachments alignment in ensurePersistedAttachmentParts explicit

The overall send pipeline looks good:

  • New messages get a group id and title via deriveChatTitle.
  • ensurePersistedAttachmentParts persists any non‑persisted attachments and rewrites corresponding file parts into data-chat-file references.
  • The same normalized parts and metadata are stored in Tinybase and sent as HyprUIMessage.

One subtle point is in how ensurePersistedAttachmentParts associates saved attachments back to parts:

const newlySaved: PersistedChatAttachment[] = [];
for (const attachment of attachments) {
  if (attachment.persisted) continue;
  const saved = await saveChatAttachment(chatGroupId, attachment.file);
  newlySaved.push(saved);
}

let savedIndex = 0;
return parts.map((part) => {
  if (part.type !== "file") return part;

  const saved = newlySaved[savedIndex++];
  if (!saved) return part;

  return {
    type: "data-chat-file",
    data: { /* from saved */ },
  };
});

This implicitly assumes:

  • Every file part in parts that needs persistence corresponds to one of the non‑persisted attachments, and
  • Their relative order matches, so incrementing savedIndex linearly is correct.

If parts ever contains additional file entries that aren’t represented in attachments (or if the ordering diverges), the tail file parts will remain as raw file parts (often with blob: URLs) and won’t go through the storage/data-chat-file path, which can be surprising.

To harden this, consider:

  • Documenting this 1:1 ordering contract where attachments is constructed, and/or
  • Passing an explicit identifier from attachments into parts (or vice versa) and matching by that id instead of by position.

That would make the persistence mapping more robust to future changes in how parts and attachments are built.

Also applies to: 187-240

apps/desktop/src/components/main/body/sessions/note-input/index.tsx (3)

318-463: Attachment ↔ editor sync and pruning logic is careful and well‑structured

The combination of:

  • collectAttachmentRefs to find referenced ids/srcs in the current document,
  • collectReferencesFromAllViews to merge in references from raw + all enhanced notes,
  • findOrphanedAttachments to filter only persisted, non‑pending, truly unreferenced attachments, and
  • syncAttachmentsWithEditor to (a) backfill missing attachmentIds based on src and (b) remove image nodes without a backing attachment,

gives a nice, conservative attachment‑cleanup story. Notably:

  • Skipping prune when any data: URLs are referenced avoids touching content while in‑flight inline data URLs are present.
  • Only pruning attachments that are persisted and not in pendingAttachmentSaves guards against races with saves.
  • Cross‑view reference collection prevents deleting attachments just because they’re not present in the currently edited view.

Given how subtle this logic is, it would be worth having a few targeted tests (or at least manual scenarios) that cover:

  • Deleting attachments referenced only in enhanced notes,
  • Session switching during save,
  • Inline copy/paste images that never become persisted attachments.

But from a code standpoint, the flow is coherent and defensive.

Also applies to: 524-575, 623-703, 864-898, 901-995


187-306: Session attachment add/save/cancel pipeline looks robust; consider minor cleanups only

The useSessionAttachments + NoteInput wiring for adding and persisting attachments is quite thorough:

  • handleFilesAdded:
    • Filters to image/*.
    • Deduplicates identical files via a signature (name-size-lastModified-type).
    • Creates optimistic attachments with object URLs and tracks them in createdAttachmentUrls.
    • Inserts corresponding image nodes into the editor (when available), with the first file honoring the provided insertion position.
  • Async saving:
    • Uses pendingAttachmentSaves to track in‑flight saves, including a cancelled flag.
    • On success, updates the optimistic attachment in place with persisted metadata (mapPersistedAttachment), clears the object URL, and updates the editor node src to the persisted fileUrl.
    • On session change or cancellation, deletes the saved attachment from storage, removes nodes from the editor, and cleans up state.
    • On error, cleans up object URLs, nodes, and attachment state.
  • Teardown:
    • cleanupObjectUrls in the effect cleanup ensures any leaked object URLs are revoked.

This is a solid pattern for optimistic UI + filesystem persistence.

Non‑blocking possible tweaks:

  • Consider gating some of the console logging (especially the verbose [attachments] logs inside hot paths) behind a debug flag if logs become noisy in production.
  • If you expect a lot of attachments per session, you could eventually clear old entries from pendingAttachmentSaves more aggressively, but with current usage it’s unlikely to matter.

Overall, the lifecycle looks correct and resilient to races (removal during save, session changes, etc.).

Also applies to: 531-575, 705-856


107-125: Editor mounting and tab behavior for attachments integrates cleanly

The additions around:

  • Forcing a fallback away from the attachments tab when shouldShowAttachmentsTab becomes false,
  • useMountedEditorView / EditorViewContainer to keep a single raw/enhanced editor mounted while switching tabs, and
  • The new alt+a hotkey in useTabShortcuts,

all fit well with the existing tab model:

  • The editor instance is reused across raw/enhanced tab changes, and file handling is only enabled when a raw editor is active (data-allow-file-drop flag).
  • Attachments tab is purely a viewer; clicks on the outer container only focus the editor when the user is on an editable tab.
  • Mounting logic gracefully falls back from an enhanced editor to raw if the enhanced note disappears.

No functional issues stand out here; just keep in mind that any future editor types that can host attachments should probably be wired into EditorViewContainer and the file‑drop gating in the same way.

Also applies to: 132-161, 187-275, 500-518, 1070-1085, 1087-1226

apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (1)

31-42: Simplify title default expression in saveSessionAttachment

{ title: file.name || file.name || "attachment" } is redundant; the second file.name never changes the result.

You can simplify without changing behavior:

-    { title: file.name || file.name || "attachment" },
+    { title: file.name || "attachment" },
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 814f89a and f1a9c39.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (25)
  • apps/desktop/package.json (1 hunks)
  • apps/desktop/src-tauri/capabilities/default.json (1 hunks)
  • apps/desktop/src/chat/resolve-attachments.ts (1 hunks)
  • apps/desktop/src/chat/transport.ts (2 hunks)
  • apps/desktop/src/chat/types.ts (1 hunks)
  • apps/desktop/src/components/chat/attachments/storage.ts (1 hunks)
  • apps/desktop/src/components/chat/input.tsx (5 hunks)
  • apps/desktop/src/components/chat/message/normal.tsx (3 hunks)
  • apps/desktop/src/components/chat/message/types.ts (1 hunks)
  • apps/desktop/src/components/chat/session.tsx (3 hunks)
  • apps/desktop/src/components/chat/view.tsx (5 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx (1 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (1 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx (3 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx (3 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/header.tsx (8 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/index.tsx (4 hunks)
  • apps/desktop/src/components/main/body/sessions/note-input/raw.tsx (5 hunks)
  • apps/desktop/src/shared/attachments/constants.ts (1 hunks)
  • apps/desktop/src/shared/attachments/storage.ts (1 hunks)
  • apps/desktop/src/store/zustand/ai-task/shared/resolve-attachments.ts (1 hunks)
  • apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts (4 hunks)
  • apps/desktop/src/store/zustand/ai-task/task-configs/enhance-workflow.ts (3 hunks)
  • apps/desktop/src/store/zustand/ai-task/task-configs/index.ts (1 hunks)
  • apps/desktop/src/store/zustand/tabs/schema.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (19)
apps/desktop/src/components/chat/message/types.ts (2)
apps/desktop/src/chat/types.ts (1)
  • ChatDataParts (19-21)
apps/desktop/src/chat/tools.ts (1)
  • Tools (45-58)
apps/desktop/src/components/chat/session.tsx (1)
apps/desktop/src/chat/transport.ts (1)
  • CustomChatTransport (13-65)
apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx (1)
apps/desktop/src/components/main/body/sessions/note-input/index.tsx (1)
  • Attachment (27-39)
apps/desktop/src/chat/transport.ts (2)
apps/desktop/src/chat/types.ts (1)
  • HyprUIMessage (23-23)
apps/desktop/src/chat/resolve-attachments.ts (1)
  • resolveChatFileReferences (6-52)
apps/desktop/src/chat/resolve-attachments.ts (2)
apps/desktop/src/chat/types.ts (1)
  • HyprUIMessage (23-23)
apps/desktop/src/components/chat/attachments/storage.ts (1)
  • readChatAttachmentAsDataURL (47-52)
apps/desktop/src/store/zustand/ai-task/shared/resolve-attachments.ts (1)
apps/desktop/src/shared/attachments/constants.ts (1)
  • ATTACHMENT_SIZE_LIMIT (1-1)
apps/desktop/src/store/zustand/ai-task/task-configs/enhance-workflow.ts (1)
apps/desktop/src/store/zustand/ai-task/shared/resolve-attachments.ts (1)
  • resolveSessionAttachments (15-65)
apps/desktop/src/store/zustand/ai-task/task-configs/enhance-transform.ts (1)
apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (1)
  • loadSessionAttachments (25-29)
apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx (1)
packages/tiptap/src/editor/index.tsx (1)
  • JSONContent (17-17)
apps/desktop/src/components/main/body/sessions/note-input/index.tsx (6)
apps/desktop/src/components/main/body/sessions/note-input/header.tsx (1)
  • useEditorTabs (456-492)
apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx (1)
  • Attachments (96-131)
apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (4)
  • loadSessionAttachments (25-29)
  • removeSessionAttachment (44-49)
  • saveSessionAttachment (31-42)
  • PersistedAttachment (8-12)
packages/tiptap/src/shared/utils.ts (2)
  • isValidTiptapContent (10-17)
  • EMPTY_TIPTAP_DOC (8-8)
apps/desktop/src/components/main/body/sessions/note-input/raw.tsx (1)
  • RawEditor (16-108)
apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx (1)
  • Enhanced (12-50)
apps/desktop/src/components/chat/view.tsx (2)
apps/desktop/src/chat/types.ts (1)
  • HyprUIMessage (23-23)
apps/desktop/src/components/chat/attachments/storage.ts (2)
  • PersistedChatAttachment (8-11)
  • saveChatAttachment (28-34)
apps/desktop/src/components/chat/attachments/storage.ts (2)
apps/desktop/src/shared/attachments/storage.ts (2)
  • ManifestEntry (15-22)
  • createAttachmentStorage (99-396)
apps/desktop/src/shared/attachments/constants.ts (1)
  • ATTACHMENT_SIZE_LIMIT (1-1)
apps/desktop/src/shared/attachments/storage.ts (2)
apps/desktop/src/components/chat/attachments/storage.ts (1)
  • ManifestCorruptionError (13-13)
apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (1)
  • ManifestCorruptionError (14-14)
apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx (1)
packages/tiptap/src/editor/index.tsx (1)
  • JSONContent (17-17)
apps/desktop/src/components/main/body/sessions/note-input/attachments/storage.ts (2)
apps/desktop/src/shared/attachments/storage.ts (2)
  • ManifestEntry (15-22)
  • createAttachmentStorage (99-396)
apps/desktop/src/shared/attachments/constants.ts (1)
  • ATTACHMENT_SIZE_LIMIT (1-1)
apps/desktop/src/components/chat/input.tsx (4)
apps/desktop/src/chat/types.ts (1)
  • HyprUIMessage (23-23)
apps/desktop/src/components/chat/attachments/storage.ts (3)
  • PersistedChatAttachment (8-11)
  • saveChatAttachment (28-34)
  • removeChatAttachment (36-41)
apps/desktop/src/hooks/useCurrentModelModalitySupport.ts (1)
  • useCurrentModelModalitySupport (21-80)
apps/desktop/src/shared/attachments/constants.ts (1)
  • ATTACHMENT_SIZE_LIMIT (1-1)
apps/desktop/src/components/chat/message/normal.tsx (1)
apps/desktop/src/components/chat/message/types.ts (1)
  • Part (7-7)
apps/desktop/src/components/main/body/sessions/note-input/raw.tsx (1)
packages/tiptap/src/shared/extensions/index.ts (1)
  • FileHandlerConfig (20-23)
apps/desktop/src/components/main/body/sessions/note-input/header.tsx (2)
apps/desktop/src/store/zustand/tabs/schema.ts (1)
  • EditorView (19-19)
apps/desktop/src/contexts/listener.tsx (1)
  • useListener (37-49)
🪛 Biome (2.1.2)
apps/desktop/src/shared/attachments/storage.ts

[error] 199-199: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 199-199: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Redirect rules - hyprnote
  • GitHub Check: Header rules - hyprnote
  • GitHub Check: Pages changed - hyprnote
  • GitHub Check: ci (macos, macos-14)
  • GitHub Check: fmt
🔇 Additional comments (21)
apps/desktop/src-tauri/capabilities/default.json (1)

72-77: Capability addition is appropriate and well-scoped.

The new fs:allow-remove permission properly enables file deletion for the attachment system while maintaining security boundaries. The path scope $DATA/hyprnote/**/* is consistent with other filesystem operations already permitted in this configuration (read, write, mkdir), and the format matches the existing pattern for fs:allow-* capabilities.

apps/desktop/src/components/main/body/sessions/note-input/raw.tsx (5)

1-12: LGTM! Clean import additions.

The new imports (useCallback and FileHandlerConfig) are properly organized and directly support the file handling functionality being added.


16-23: Well-structured prop extension.

The component signature cleanly extends the API to support file attachments and content change notifications while maintaining backward compatibility through optional callbacks.


49-63: Excellent callback implementation.

The handleChange properly orchestrates content persistence and change notification with correct memoization and dependencies. The ordering (persist first, then notify) is sensible.


75-95: Solid file handler implementation.

The fileHandlerConfig correctly implements both drop and paste handlers:

  • onDrop forwards the provided position parameter
  • onPaste derives the cursor position from editor.state.selection.from (standard TipTap/ProseMirror API)
  • Returning false prevents default file handling behavior
  • The satisfies operator ensures type safety

97-107: Clean integration with NoteEditor.

The fileHandlerConfig is properly passed through, enabling file handling when the callback is provided while maintaining backward compatibility when it's not.

apps/desktop/src/shared/attachments/constants.ts (1)

1-1: LGTM!

The 10 MB attachment size limit is clearly defined and appropriately scoped for use across the attachment system.

apps/desktop/src/components/main/body/sessions/note-input/enhanced/index.tsx (2)

3-3: LGTM!

The JSONContent import is correctly added to support the new callback prop type.


14-18: LGTM!

The onContentChange callback is cleanly integrated as an optional prop and properly forwarded to the EnhancedEditor. The implementation is backward compatible and type-safe.

Also applies to: 47-47

apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx (1)

1-1: LGTM!

The refactoring properly separates content persistence (saveContent) from the change handler and uses useCallback with correct dependencies. The optional onContentChange callback is safely invoked only when provided.

Also applies to: 41-55

apps/desktop/src/components/main/body/sessions/note-input/attachments/index.tsx (1)

133-139: LGTM!

The formatAttachmentTimestamp helper properly handles invalid dates by returning the original value, preventing runtime errors.

apps/desktop/src/store/zustand/tabs/schema.ts (1)

13-13: LGTM!

The new "attachments" view type and corresponding type guard follow the established patterns for editor view variants. The implementation is consistent and type-safe.

Also applies to: 29-31

apps/desktop/src/chat/types.ts (1)

10-23: LGTM!

The new ChatFileReferencePart and ChatDataParts types cleanly extend the AI SDK's UIMessage with custom data parts for attachment references. The type structure aligns well with the attachment system introduced in this PR.

apps/desktop/src/store/zustand/ai-task/task-configs/index.ts (1)

47-53: LGTM!

The attachments array addition to the enhance task arguments is well-typed with all necessary metadata fields (id, fileName, mimeType, size, fileUrl).

Note: The field is named fileName here while ChatFileReferencePart in apps/desktop/src/chat/types.ts uses filename. Ensure this naming difference is intentional based on the different contexts (task config vs. chat message parts).

apps/desktop/package.json (1)

76-76: Dependency version verified and no security vulnerabilities found.

The async-mutex package version 0.5.0 exists in the npm registry and has no known security vulnerabilities. The dependency addition is appropriate for the concurrency control requirements.

apps/desktop/src/components/chat/message/types.ts (1)

5-7: Part typing updated correctly to support data parts

Using UIMessagePart<ChatDataParts, Tools> wires in the new data-chat-file parts cleanly while preserving tool-part typing; this looks consistent with the rest of the chat types.

apps/desktop/src/components/chat/session.tsx (1)

25-31: chatGroupId propagation into transport is consistent and hook-safe

Passing chatGroupId through useTransport into CustomChatTransport and memoizing on [registry, model, chatGroupId] keeps the hook order stable and correctly scopes the transport per chat group.

Also applies to: 162-173

apps/desktop/src/components/chat/message/normal.tsx (1)

66-83: New branches for file/data-chat-file parts match the updated Part union

Routing "file" parts to FileAttachment and "data-chat-file" parts to ChatFileAttachment aligns with the Part typing and keeps existing reasoning/text/tool handling untouched.

apps/desktop/src/components/chat/view.tsx (1)

242-261: Chat title derivation behavior looks reasonable

The deriveChatTitle helper’s precedence (trimmedContent → first attachment filename → "New chat") and 50‑character ellipsis cutoff provide a clear, predictable title without overcomplicating things. This should work well both for text‑only chats and file‑only chats.

apps/desktop/src/store/zustand/ai-task/shared/resolve-attachments.ts (1)

21-65: Fix base64 conversion to handle large files safely—use modern native API or FileReader fallback

The bug is confirmed. Line 88 in readAttachmentAsDataURL uses btoa(String.fromCharCode(...new Uint8Array(fileData))), which crashes when spreading large Uint8Arrays into function arguments (JavaScript engines limit to ~65k arguments; 10MB file = ~10M arguments).

The @tauri-apps/plugin-fs v2 readFile returns a Promise, and the current code is unsafe for the configured 10MB limit.

Rather than chunking String.fromCharCode, use the modern native approach or a robust fallback:

   try {
     const fileData = await readFile(relativePath, {
       baseDir: BaseDirectory.Data,
     });

-    const base64 = btoa(String.fromCharCode(...new Uint8Array(fileData)));
-
+    // Use native Uint8Array.toBase64() if available (Stage 4, Sept 2025+)
+    // Otherwise fall back to Blob + FileReader for broader compatibility
+    let base64: string;
+    if (typeof (new Uint8Array()).toBase64 === 'function') {
+      base64 = new Uint8Array(fileData).toBase64();
+    } else {
+      const blob = new Blob([fileData], { type: mimeType });
+      base64 = await new Promise<string>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+          const result = reader.result as string;
+          resolve(result.split(',')[1]); // extract base64 part
+        };
+        reader.onerror = reject;
+        reader.readAsDataURL(blob);
+      });
+    }
+
     return `data:${mimeType};base64,${base64}`;
   } catch (error) {

This avoids argument-limit failures and uses the fastest available method. Modern Uint8Array.toBase64() is fastest; FileReader is a robust alternative.

Likely an incorrect or invalid review comment.

apps/desktop/src/components/chat/attachments/storage.ts (1)

8-52: Chat attachment storage wrapper looks consistent

The typed wrapper cleanly mirrors the shared storage API, and PersistedChatAttachment correctly matches ManifestEntry & { filePath; fileUrl }. Config (base path, size limit, no title) is appropriate for chat usage.

Comment on lines +17 to 18
private chatGroupId?: string,
) {}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against attachment resolution failures so sends still succeed

Right now, if resolveChatFileReferences throws (e.g., readChatAttachmentAsDataURL fails), sendMessages will reject and the whole send will fail, even though we could safely fall back to the original messages without resolved files.

To keep chat more robust (and consistent with the more defensive session attachment resolver), consider wrapping the resolver in a try/catch and defaulting back to options.messages on error:

-    const resolvedMessages = await resolveChatFileReferences(
-      options.messages,
-      this.chatGroupId,
-    );
+    let resolvedMessages = options.messages;
+    try {
+      resolvedMessages = await resolveChatFileReferences(
+        options.messages,
+        this.chatGroupId,
+      );
+    } catch (error) {
+      console.error(
+        "[chat] failed to resolve attachments; falling back to original messages",
+        error,
+      );
+    }

This preserves the new attachment support while avoiding a hard failure path when resolution misbehaves.

Also applies to: 25-29, 43-45

🤖 Prompt for AI Agents
In apps/desktop/src/chat/transport.ts around lines 17-18 (also apply same change
at 25-29 and 43-45): the calls that run resolveChatFileReferences can throw and
currently bubble up, causing sendMessages to fail; wrap each
resolveChatFileReferences invocation in a try/catch, on catch log or warn the
error, and fall back to using the original options.messages (or the previous
messages variable) so the send proceeds without resolved attachments; ensure
types remain compatible when falling back and avoid swallowing unexpected errors
(only suppress attachment-resolution failures).

Comment on lines +165 to +205
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
const validFiles: Array<{
file: File;
persisted?: PersistedChatAttachment;
}> = [];
let errorMessage: string | null = null;

for (const file of Array.from(files)) {
if (file.size > ATTACHMENT_SIZE_LIMIT) {
errorMessage = `File "${file.name}" exceeds ${Math.round(ATTACHMENT_SIZE_LIMIT / 1024 / 1024)}MB limit`;
break;
}

if (chatGroupId) {
try {
const persisted = await saveChatAttachment(chatGroupId, file);
validFiles.push({ file, persisted });
} catch (error) {
errorMessage =
error instanceof Error ? error.message : String(error);
break;
}
} else {
validFiles.push({ file });
}
}

if (errorMessage) {
setAttachmentError(errorMessage);
} else {
setAttachmentError(null);
setAttachedFiles((prev) => [...prev, ...validFiles]);
}

if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid leaving persisted attachments without UI references on partial failures

In handleFileChange, if some files are successfully persisted (pushed into validFiles) and a later file triggers errorMessage (size or saveChatAttachment error), you break the loop and then skip setAttachedFiles entirely in the errorMessage branch. That leaves already‑persisted attachments on disk and in the manifest but not in attachedFiles, so the user can’t see or clean them up via the UI.

You can keep the current “stop on first error” behavior but always add successfully processed files to state, even when an error occurred:

-        if (errorMessage) {
-          setAttachmentError(errorMessage);
-        } else {
-          setAttachmentError(null);
-          setAttachedFiles((prev) => [...prev, ...validFiles]);
-        }
+        if (errorMessage) {
+          setAttachmentError(errorMessage);
+        } else {
+          setAttachmentError(null);
+        }
+
+        if (validFiles.length > 0) {
+          setAttachedFiles((prev) => [...prev, ...validFiles]);
+        }

This keeps the UX (surface the error) while ensuring all successfully saved attachments are tracked in component state.

🤖 Prompt for AI Agents
In apps/desktop/src/components/chat/input.tsx around lines 165-205, the handler
stops on first error and only updates attachedFiles when there was no error,
which can leave already-persisted attachments orphaned; change the logic so
after the for-loop (regardless of whether errorMessage was set) you always
append the validFiles to state via setAttachedFiles(prev => [...prev,
...validFiles]), then setAttachmentError if errorMessage exists (or null if
not), and finally clear fileInputRef; keep the “stop on first error” behavior
inside the loop but ensure persisted entries are always added to component state
even when a later file fails.

Comment on lines +65 to +80
function HeaderTabAttachments({
isActive,
onClick = () => {},
onUploadClick,
}: {
isActive: boolean;
onClick?: () => void;
onUploadClick?: () => void;
}) {
const handleUploadClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
onUploadClick?.();
},
[onUploadClick],
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Import React for namespace types or switch to named event types

HeaderTabAttachments and handleAttachmentInputChange use React.MouseEvent / React.ChangeEvent, but this file only imports hooks from "react" and doesn’t bring the React namespace into scope. In TS with the modern JSX runtime, JSX itself doesn’t require React, but these type references still do, so this will fail type‑checking.

Two straightforward fixes:

  1. Add a type‑only React import and keep the current annotations:
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type React from "react";
  1. Or, switch to named event types (no React namespace needed):
-  const handleUploadClick = useCallback(
-    (event: React.MouseEvent) => {
+  const handleUploadClick = useCallback(
+    (event: MouseEvent) => {
@@
-  const handleAttachmentInputChange = useCallback(
-    (event: React.ChangeEvent<HTMLInputElement>) => {
+  const handleAttachmentInputChange = useCallback(
+    (event: ChangeEvent<HTMLInputElement>) => {

and update the top import accordingly:

-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+  type ChangeEvent,
+  type MouseEvent,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from "react";

Either approach will restore type safety and compile cleanly.

Also applies to: 362-372


I'll verify this review comment by inspecting the actual file and checking the imports and code usage.


🏁 Script executed:

#!/bin/bash
# Find and read the header.tsx file to check imports and target lines
find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Read the file to examine imports and relevant code sections
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
if [ -n "$file_path" ]; then
  echo "=== TOP 40 LINES (IMPORTS) ==="
  head -40 "$file_path"
  echo ""
  echo "=== LINES 60-85 ==="
  sed -n '60,85p' "$file_path"
  echo ""
  echo "=== LINES 355-375 ==="
  sed -n '355,375p' "$file_path"
else
  echo "File not found"
fi

Length of output: 2526


🏁 Script executed:

#!/bin/bash
# Search for all React.MouseEvent and React.ChangeEvent usages in the file
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
if [ -n "$file_path" ]; then
  echo "=== React.MouseEvent usages ==="
  rg -n "React\.MouseEvent" "$file_path"
  echo ""
  echo "=== React.ChangeEvent usages ==="
  rg -n "React\.ChangeEvent" "$file_path"
fi

Length of output: 196


🏁 Script executed:

#!/bin/bash
# Search for React import statements in the file
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
if [ -n "$file_path" ]; then
  echo "=== All import statements ==="
  rg -n "^import" "$file_path"
fi

Length of output: 931


🏁 Script executed:

#!/bin/bash
# Verify if React is imported as namespace or value
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
if [ -n "$file_path" ]; then
  echo "=== React namespace or value imports ==="
  rg -n "import.*React" "$file_path" | head -20
fi

Length of output: 40


🏁 Script executed:

#!/bin/bash
# Check tsconfig.json jsx setting to understand how React types are resolved
find . -name "tsconfig.json" -o -name "tsconfig.*.json" | xargs -I {} sh -c 'echo "=== {} ===" && jq -r ".compilerOptions | {jsx, jsxImportSource}" {} 2>/dev/null || echo "Could not parse"' 2>/dev/null | head -50

Length of output: 932


🏁 Script executed:

#!/bin/bash
# Check package.json React version
if [ -f "package.json" ]; then
  echo "=== React version in package.json ==="
  jq -r '.dependencies.react // .devDependencies.react // "not found"' package.json
fi

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Double-check the exact content around the function definition
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
if [ -n "$file_path" ]; then
  echo "=== Complete context of HeaderTabAttachments (lines 60-100) ==="
  sed -n '60,100p' "$file_path"
fi

Length of output: 994


🏁 Script executed:

#!/bin/bash
# Read the desktop tsconfig.json directly to check jsx setting
cat ./apps/desktop/tsconfig.json

Length of output: 604


🏁 Script executed:

#!/bin/bash
# Search for all React.* type usages to get full scope
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
rg -n "React\.[A-Z]" "$file_path"

Length of output: 195


🏁 Script executed:

#!/bin/bash
# Verify if TypeScript would actually fail—check if this file has tsconfig refs
file_path=$(find . -type f -name "header.tsx" -path "*/sessions/note-input/*" | head -1)
dir=$(dirname "$file_path")
# Walk up and find the nearest tsconfig or package.json
while [ "$dir" != "." ] && [ "$dir" != "/" ]; do
  if [ -f "$dir/tsconfig.json" ]; then
    echo "Found tsconfig.json at: $dir/tsconfig.json"
    head -20 "$dir/tsconfig.json"
    break
  fi
  dir=$(dirname "$dir")
done

Length of output: 536


🏁 Script executed:

#!/bin/bash
# Check if there are any type or value React imports elsewhere in related files
find ./apps/desktop/src/components/main/body/sessions -name "*.tsx" -o -name "*.ts" | xargs rg "^import.*React" | head -20

Length of output: 367


Import React for namespace types or switch to named event types

The file uses React.MouseEvent, React.ChangeEvent, and React.ReactNode without importing the React namespace. With jsx: "react-jsx" and strict: true, TypeScript compilation will fail.

Instances found:

  • Line 44: React.ReactNode
  • Line 75: React.MouseEvent (in HeaderTabAttachments)
  • Line 136: React.MouseEvent (additional handler)
  • Line 363: React.ChangeEvent<HTMLInputElement> (in handleAttachmentInputChange)
  • Line 604: React.ReactNode

Two straightforward fixes:

  1. Add a type‑only React import:
-import { useCallback, useEffect, useRef, useState } from "react";
+import type React from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
  1. Or, switch to named imports (e.g., MouseEvent, ChangeEvent, ReactNode):
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+  type ChangeEvent,
+  type MouseEvent,
+  type ReactNode,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from "react";

Then replace all React.* type usages accordingly.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/components/main/body/sessions/note-input/header.tsx (see
occurrences around lines 44, 65–80, 136, 363, 604), the code uses React
namespace types (React.ReactNode, React.MouseEvent, React.ChangeEvent) but does
not import React, which breaks TypeScript under jsx: "react-jsx" and strict
mode; fix by adding a type-only import from 'react' (e.g., import type React
from "react";) or import the specific types as named type imports (e.g., import
type { MouseEvent, ChangeEvent, ReactNode } from "react";) and then update the
type references accordingly (either keep React.* or switch to the named types),
ensuring no runtime import is added.

Comment on lines +197 to +202
function sanitizeFileName(name: string) {
const sanitized = name
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
.slice(0, 255);
return sanitized.length > 0 ? sanitized : "attachment";
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Address lint error for control characters in filename sanitization

The regex in sanitizeFileName intentionally strips ASCII control characters via \u0000-\u001F, but Biome flags this as a control‑character regex error. To avoid the lint error while keeping behavior, you can move the control‑character check out of the regex:

-  function sanitizeFileName(name: string) {
-    const sanitized = name
-      .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
-      .slice(0, 255);
-    return sanitized.length > 0 ? sanitized : "attachment";
-  }
+  function sanitizeFileName(name: string) {
+    const sanitized = Array.from(name)
+      .map((char) => {
+        const code = char.charCodeAt(0);
+        if (code < 32 || /[<>:"/\\|?*]/.test(char)) {
+          return "_";
+        }
+        return char;
+      })
+      .join("")
+      .slice(0, 255);
+    return sanitized.length > 0 ? sanitized : "attachment";
+  }

This preserves the “strip invalid filesystem and control chars” behavior but should satisfy the linter.

🧰 Tools
🪛 Biome (2.1.2)

[error] 199-199: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 199-199: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)

🤖 Prompt for AI Agents
In apps/desktop/src/shared/attachments/storage.ts around lines 197 to 202, the
regex currently includes the control-character range \u0000-\u001F which
triggers a linter error; remove that range from the regex used to strip
filesystem-invalid characters, then perform a separate pass to remove control
characters (e.g., filter characters by codePoint <= 0x1F or use a replace with a
predicate) either before or after the regex replacement, preserve the .slice(0,
255) truncation and the fallback to "attachment" when the result is empty, and
ensure the final output remains the same while avoiding control-character ranges
in the regex.

Comment on lines +334 to +346
async function removeAll(id: string) {
const baseDir = BASE_DIR(id);
const dirExists = await exists(baseDir, {
baseDir: BaseDirectory.Data,
});

if (dirExists) {
await removeFile(baseDir, {
baseDir: BaseDirectory.Data,
recursive: true,
});
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard removeAll with the same mutex used for other manifest operations

removeAll currently bypasses the per‑id mutex, while load, save, remove, and readAsDataURL all run inside mutex.runExclusive. That means removeAll can race with in‑flight operations on the same id, potentially deleting directories while another call is reading or writing the manifest/files.

Consider wrapping removeAll in the same mutex pattern:

-  async function removeAll(id: string) {
-    const baseDir = BASE_DIR(id);
-    const dirExists = await exists(baseDir, {
-      baseDir: BaseDirectory.Data,
-    });
-
-    if (dirExists) {
-      await removeFile(baseDir, {
-        baseDir: BaseDirectory.Data,
-        recursive: true,
-      });
-    }
-  }
+  async function removeAll(id: string) {
+    const mutex = getManifestMutex(id);
+    return await mutex.runExclusive(async () => {
+      acquireMutex(id);
+      try {
+        const baseDir = BASE_DIR(id);
+        const dirExists = await exists(baseDir, {
+          baseDir: BaseDirectory.Data,
+        });
+
+        if (dirExists) {
+          await removeFile(baseDir, {
+            baseDir: BaseDirectory.Data,
+            recursive: true,
+          });
+        }
+      } finally {
+        releaseMutex(id);
+      }
+    });
+  }

This keeps all per‑id filesystem changes serialized.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/shared/attachments/storage.ts around lines 334 to 346,
removeAll is not using the per-id mutex and can race with
load/save/remove/readAsDataURL; fix by running the existing per-id mutex for
this id (the same mutex used by other manifest ops) and move the current body
into a mutex.runExclusive(async () => { ... }) call so the exists/removeFile
calls are executed under the lock, preserving the same error propagation/return
behavior.

Comment on lines +348 to +383
async function readAsDataURL(
id: string,
attachmentId: string,
): Promise<string | null> {
const mutex = getManifestMutex(id);
return await mutex.runExclusive(async () => {
acquireMutex(id);
try {
const manifest = await readManifest(id);
const entry = manifest[attachmentId];

if (!entry) {
return null;
}

const relativePath = ATTACHMENT_FILE_PATH(
id,
attachmentId,
entry.fileName,
);
const existsOnDisk = await exists(relativePath, {
baseDir: BaseDirectory.Data,
});

if (!existsOnDisk) {
return null;
}

const fileData = await readFile(relativePath, {
baseDir: BaseDirectory.Data,
});

const base64 = btoa(String.fromCharCode(...new Uint8Array(fileData)));

return `data:${entry.mimeType};base64,${base64}`;
} finally {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Avoid String.fromCharCode(...bytes) for base64 conversion to prevent crashes on larger files

In readAsDataURL, this line:

const base64 = btoa(String.fromCharCode(...new Uint8Array(fileData)));

spreads every byte as a separate argument to String.fromCharCode. For multi‑MB files (you allow up to 10 MB), this can easily exceed the engine’s argument limit and throw a RangeError, even though the file size is within your configured limit.

Switch to a chunked conversion to stay within safe argument counts:

-        const fileData = await readFile(relativePath, {
-          baseDir: BaseDirectory.Data,
-        });
-
-        const base64 = btoa(String.fromCharCode(...new Uint8Array(fileData)));
-
-        return `data:${entry.mimeType};base64,${base64}`;
+        const fileData = await readFile(relativePath, {
+          baseDir: BaseDirectory.Data,
+        });
+
+        const bytes = new Uint8Array(fileData);
+        const chunkSize = 8_192;
+        let binary = "";
+
+        for (let i = 0; i < bytes.length; i += chunkSize) {
+          // Build the binary string in manageable chunks to avoid exceeding the
+          // argument limit of String.fromCharCode.
+          binary += String.fromCharCode(
+            ...bytes.subarray(i, i + chunkSize),
+          );
+        }
+
+        const base64 = btoa(binary);
+        return `data:${entry.mimeType};base64,${base64}`;

This keeps memory usage reasonable and avoids call‑stack/argument‑limit issues for attachments up to your configured max size.

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.

2 participants