Skip to content

feat: Add Cairn mobile companion app and desktop Device Sync - #76

Merged
ddutchie merged 74 commits into
mainfrom
ddutchie/expomobile
Jul 7, 2026
Merged

ddutchie merged 74 commits into
mainfrom
ddutchie/expomobile

Conversation

@ddutchie

@ddutchie ddutchie commented Jul 6, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

Introduces the Cairn mobile companion app (an offline-first Expo iOS/Android client) and the desktop-side Device Sync feature that connects the two via a shared cloud folder using an append-only oplog. This branch adds the v2.4.0 changelog, a mobile-app viability/planning doc, and updates .gitignore to track example env files and ignore mobile/node_modules.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

Checklist

  • npm run type-check:all passes
  • npm run lint passes
  • npm test passes (runs npm run compile first so electron/bundle-guard.test.ts actually executes — npm run test:bundle to run just that)
  • npm run test:e2e passes (run before merging UI changes or cutting a release)
  • No hardcoded colours — CSS variables only (var(--accent), var(--text-primary), etc.)
  • No text-[Npx] pixel font classes — rem equivalents only (text-[0.714rem], text-xs, etc.)
  • New IPC handlers wrapped in handle() and return IpcResult<T>
  • New DB migrations appended (not edited) in schema.ts
  • New SQL goes in electron/db/queries.ts — single source of truth (imported by both Electron main process and MCP server); never construct a Database instance outside db/client.ts (Electron) or mcp-server.ts (MCP runtime)
  • New dependencies or devDependencies added to ROLE_MAP in scripts/generate-licenses.js and licenses.json regenerated
  • New MCP tools registered in electron/mcp/tools/index.ts dispatch + electron/lib/tool-schemas.ts Zod schema
  • If you added/removed a --external:<pkg> flag in the compile script: updated the appropriate allowlist group (RUNTIME_PROVIDED / OPTIONAL_TRANSITIVE / SUBPROCESS_ONLY / SHIPPED_NATIVE) in electron/bundle-guard.test.ts and (if SHIPPED_NATIVE) shipped the package via electron-builder.yml

Notes for reviewer

This diff currently covers the documentation and repo-hygiene groundwork for the mobile/Device Sync effort:

  • .gitignore: now force-tracks .env.example / **/.env.example (template files with no secrets) while still ignoring real .env* files, and ignores /mobile/node_modules.
  • changelogs/v2.4.0.md: user-facing release notes describing the mobile companion app, Device Sync, conflict-safe reconciliation (last-writer-wins + preserved "conflicted copy"), and the pluggable OpenAI-compatible mobile AI backend.
  • docs/plans/mobile-app-viability.md: a design/planning document covering the sync engine strategy (synced-folder oplog, tombstones, changelog/oplog, HLC/CAS concurrency, and the gaps in the current schema that must be closed).

Key decisions worth confirming:

  • Sync transport is a synced-folder oplog (no backend); only append-only oplog files transit the shared folder, never the DB.
  • Scope is 2 personal devices / single user, which simplifies the conflict model.
  • The viability doc calls out schema gaps (missing tombstones, no changelog/oplog, tags lacking timestamps, advisory-only version) — please review whether those are accurately captured before the corresponding schema.ts migrations land in a follow-up PR.

Since this branch appears to be docs + config only, the code-oriented checklist items above are not yet applicable and will be addressed as the implementation PRs follow.

Summary by CodeRabbit

  • New Features
    • Added a mobile companion app (projects, notes, tasks/board, search, chat, sync, and conflict resolution).
    • Introduced offline-first device sync with a configurable shared sync folder and sync status metrics.
    • Added AI-powered chat with provider auto-selection, attachments, and editable chat UI.
  • Bug Fixes
    • Prevented deleted items from being resurrected during sync and improved hard-delete safety.
  • Documentation
    • Added mobile setup/readme, environment examples, and sync/viability planning docs.
  • Tests / Chores
    • Expanded sync/formatting/shared unit tests; updated linting/type-check scripts and sync-related tooling/config.

ddutchie added 30 commits July 2, 2026 11:04
Viability exploration + phased roadmap for an Expo mobile companion:
- standalone offline-first app with its own SQLite DB
- synced-folder oplog transport (never syncs the binary cairn.db)
- 2 personal devices, single user; LWW + conflict-copy (no CRDTs)
- online-only chat via Rork toolkit + Vercel AI SDK
- MVP scope: notes, board, search, chat, lightweight knowledge graph
Proven bidirectional sync engine for the mobile app, plus the desktop
schema + capture hook it runs on.

Engine (electron/sync/):
- hlc.ts: Hybrid Logical Clock — skew-tolerant, monotonic, comparable
- engine.ts: HLC-ordered oplog replay; LWW + note-body conflict-copy;
  set-union merge for JSON-array cols; delete-safe tombstones; idempotent
- transport.ts: append-only per-device NDJSON oplog in a synced folder
  (never touches the binary cairn.db)
- drainPending(): converts trigger-staged changes into HLC-stamped ops,
  coalescing edit bursts

Schema (migrations v25 + v26):
- hlc + deleted_at (tombstone) on all 8 syncable tables
- created_at/updated_at on tags (previously had none)
- sync_oplog / sync_pending / sync_state tables
- capture triggers stage every INSERT/UPDATE/DELETE regardless of write
  path (renderer IPC, MCP, file-watcher), gated by a suppress flag

Verified: 11 sync tests (convergence, LWW, conflict-copy, no-resurrection,
out-of-order, union merge, idempotency, real queries.ts capture, burst
coalescing, in-place v24 upgrade). 198 db/ipc/sync tests green, no
regressions. type-check:all + eslint clean.

Note: drainPending() is tested but not yet called in the live main loop
(post-write + periodic + powerMonitor resume) — tracked as follow-up.
Move the sync engine out of electron/ into a source-only shared/ folder so
the desktop and future Expo app run the identical code — one shared brain,
two UI shells (see plan §3).

- electron/sync/* -> shared/sync/* (hlc, engine, transport, schema, tests)
- Introduce SyncDb adapter interface (shared/sync/db-adapter.ts): the engine
  no longer depends on better-sqlite3. Desktop satisfies it directly;
  expo-sqlite will get a thin adapter in P3.
- Public API barrel (shared/sync/index.ts) + shared/README.md documenting the
  no-platform-deps rule.
- Build wiring: tsconfig.shared.json + type-check:shared (added to
  type-check:all); vitest node project includes shared/**; renderer tsconfig
  excludes shared/. No esbuild pipeline changes — electron imports are
  relative, Expo will alias later.

Chose a lightweight shared source folder over a full workspaces monorepo:
reversible, keeps the current build intact, and avoids restructuring the
pipeline before the Expo consumer exists.

Verified: type-check:all (renderer+electron+shared) clean; npm run compile
clean; 199 db/ipc/sync tests green; eslint clean.
Scaffold the standalone offline-first Cairn mobile app (Expo Router):
- expo-sqlite local DB running the shared syncable schema + capture triggers
- expo-sqlite adapter over the portable SyncDb interface so the app runs the
  IDENTICAL @shared/sync engine the desktop uses
- read-only MVP screens: Notes, Board, Search + note detail
- Sync tab: import desktop oplog .ndjson via document picker -> applyRemote()

On Expo SDK 57 (RN 0.86, React 19.2.3, TS 6.0). expo-doctor 20/20,
mobile type-check clean, shared sync tests 11/11.

Refs P3 (docs/plans/mobile-app-viability.md)
Metro/expo export only serve files under the project root, so the
out-of-root <repo>/shared brain could not be bundled (Unable to resolve
module). Consume it as a normal package instead:

- shared/package.json: name @cairn/shared with subpath exports pointing at
  the TS source (Metro/babel transpile directly). Desktop still imports via
  relative paths, unaffected.
- mobile/scripts/link-shared.js: postinstall symlinks <repo>/shared ->
  node_modules/@cairn/shared (idempotent, relative, EAS-compatible).
- metro.config.js: enable unstable_enableSymlinks; drop the resolveRequest
  alias hack (no longer needed).
- babel.config.js: drop the @shared module-resolver alias (produced a wrong
  relative path); Metro handles resolution now.
- imports + tsconfig paths: @shared/* -> @cairn/shared/*.

Verified: mobile type-check clean, expo export --platform ios bundles the
shared SyncEngine successfully.
expo prebuild updated the ios/android scripts to expo run:* (dev client)
since the app uses native modules (expo-sqlite). This is the correct
workflow vs Expo Go for SDK 57.
…plugin)

xcodebuild's ibtool step fails with 'iOS 26.5 Platform Not Installed' when
compiling the generated SplashScreen.storyboard on setups where the matching
simulator runtime's cryptex disk image isn't fully mounted. The launch screen
is cosmetic, so replace it with the modern UILaunchScreen Info.plist dict
(no ibtool) via a durable Expo config plugin that also strips the storyboard
from the Resources build phase.

Survives expo prebuild (verified: prebuild --clean produces a project with
UILaunchScreen set and 0 storyboard build-phase refs). The app + all pods +
the shared @cairn/shared SyncEngine already compile and link (625 build
steps); this removes the last build-time blocker that isn't a machine issue.

The remaining blocker to actually launching is machine-level only: the iOS
26.5 simulator runtime disk image is downloaded but not mounted (needs a Mac
reboot or sudo). No further code changes required to run.
…lash

The splash-storyboard ibtool failure was caused by an incompatible iOS 26.5
simulator runtime build (23F73) vs Xcode 26.6's SDK (23F81a). Installing the
matching runtime build (23F77) fixed it: ibtool now compiles
SplashScreen.storyboard cleanly (verified in a real build — CompileStoryboard
runs with 0 errors, no 'Platform Not Installed').

So the withPlistLaunchScreen config plugin is no longer needed. Removed the
plugin + its app.json registration; expo prebuild --clean restores the
standard storyboard launch screen. Build Succeeded, app installs + launches.

(.gitignore change is expo prebuild auto-appending expo-env.d.ts.)
- Native platform tab bar (expo-router/unstable-native-tabs) with SF Symbols /
  Material icons + labels, replacing the icon-less JavaScript <Tabs>. Search
  uses iOS 26 role=search. Wrapped in ThemeProvider (avoids tab-switch flash).
- Shared <Screen> scaffold applies safe-area insets so content no longer
  renders under the status bar / notch (native tabs have no header).
- App icon: Cairn brand mark (assets/icon.png) + Android adaptive icon.
- Bundle id -> com.gerardslee.cairn; withAppleSigning config plugin sets the
  Apple team (DA48HNTSEZ) + automatic signing durably across prebuild, fixing
  the instant __abort_with_payload launch crash on a physical device.
- folder.ts: connect an iCloud/Files sync folder via Directory.pickDirectoryAsync
  (persistent security-scoped URL stored in sync_state); reconnects on launch.
- fs-transport.ts: read/write per-device oplog-<id>.ndjson files with the SDK 57
  File/Directory API, mirroring the desktop synced-folder layout exactly.
- sync.ts: syncNow() runs the full round-trip on the shared SyncEngine —
  drainPending -> publish own oplog -> read peers -> applyRemote (LWW + array
  union + conflict-copy) -> re-publish. Convergent, offline-first.
- queries.updateNote(): plain UPDATE so capture triggers stage the edit; note
  detail screen gains an Edit/Save mode.
- Sync tab rewritten: connect/disconnect folder, connection + pending status,
  Sync now, and a result summary (sent / applied / conflict copies).

Read-only sync-in.ts superseded and removed. type-check clean.
The oplog is a changelog, so a fresh peer never receives rows created before
the engine started capturing. This wires up the missing desktop half so the
phone gets the FULL existing workspace + ongoing changes.

- engine.backfill(): one-time, idempotent seed of a coalesced 'put' per live
  row across all syncable tables (reads the authoritative SQLite copy). Stays
  proportional to live-row count, not edit history. +test.
- electron/sync/desktop-sync.ts: SyncEngine on the app DB, sync-folder persisted
  in sync_state, drain + full round-trip (backfill -> drain -> publish own oplog
  -> read peers -> applyRemote -> re-publish) over the Node transport.
- electron/sync/sync-handlers.ts: sync:getFolder/selectFolder/clearFolder/now.
- main.ts: drive it — 5s drain, 30s full sync, plus resume/focus/before-quit/
  startup (JS timers pause during sleep). Notifies renderer on inbound changes.
- preload: window.electron.sync.*; SyncSettings 'Device Sync' pane (choose the
  shared cloud folder, Sync now, sent/received/conflicts).
- tsconfig: electron/sync/* type-checked via tsconfig.shared.json (it imports
  the repo-root shared engine, outside the electron rootDir); lazy require in
  main.ts/handlers.ts keeps it out of the electron program. Exclude mobile/ from
  the root renderer tsconfig.
- mobile: getDb/getEngine lazy-init (native tabs mount screens eagerly, so a
  query can fire before RootLayout's effect — fixes the 'DB not initialised'
  crash).

type-check:all clean; compile clean; shared+db tests green (81).
Previously applyRemote wrote inbound peer changes to cairn.db only, so a note
edited on the phone updated the desktop DB but left the on-disk .md stale (and
the file-watcher could later re-import the stale .md and clobber the synced
edit).

- engine.applyRemote now returns  (the ops that actually changed local
  state), so the desktop knows which notes to re-emit.
- desktop-sync projects each inbound note change onto disk via writeNoteFile /
  deleteNoteFile (conflict copies get .md files too), echo-suppressed through
  suppressNextChange so the file-watcher ignores our own write.
- setNoteFileProjector registers the projector once (main.ts owns workspacePath
  + notes-io/file-watcher), used by both the periodic loop and manual sync:now.

Net effect: edit on either device and the desktop's .md files stay in lock-step
with the synced DB (Obsidian/git see the change). type-check clean, shared+db
tests green (70).
Bring the mobile app to parity with the desktop's structure, styling, and
note rendering (was a flat, unstyled, plaintext MVP).

Hierarchy (matches desktop workspace -> project -> notes/board):
- Projects tab (was flat Notes): project cards with icon + note/task counts.
- project/[id]: segmented Notes / Board view. Notes grouped by folder via
  SectionList (folder headers); Board shows columns + priority-dotted cards.
  Board is no longer a top-level tab — it lives inside a project.
- New queries: getProject, listProjectSummaries (counts), listFolders.

Markdown:
- MarkdownView (react-native-markdown-display, pure JS) themed from the shared
  tokens. Note detail renders real markdown (headings, code, tables, lists,
  quotes, links) instead of plaintext; Edit/Save still writes back through the
  capture triggers -> sync.

Theme:
- src/theme.ts mirrors the desktop CSS custom properties (globals.css) as JS
  tokens with dark/light + useTheme() following the system scheme. Every screen
  (Projects, project detail, Search, Sync, note detail, Screen scaffold, root
  Stack headers) restyled from tokens. PRIORITY_COLOR shared.

mobile type-check clean; Metro bundles (6.8MB, all routes + markdown resolve).
A note/card body containing literal <repo>, <TOKEN>, <API_KEY>, or a generic
like Array<T> crashed the renderer: rehypeRaw parses them as raw HTML, React
then throws 'The tag <repo> is unrecognized'. Hit e.g. on a Kanban card whose
description previews through NoteMarkdownPreview.

Add rehypeEscapeUnknownTags (runs right after rehypeRaw in all three
rehypeRaw consumers — NoteMarkdownPreview, note-editor read mode, chat
MarkdownContent). It converts any element whose tagName isn't a real HTML/
SVG/MathML tag (nor a pipeline-custom tag: mark/callout/mathblock) back into a
literal text node, so the source renders as typed. Real inline HTML, KaTeX,
and callouts are preserved.

Verified: unknown tags -> text, <strong>/known tags survive. type-check clean.
…board

Reuse strategy: extract pure logic to shared/ so both apps use identical code
(RN can't render desktop DOM components, but the *logic* is shared).

Shared (new):
- shared/notes/folder-tree.ts — buildFolderTree, generic over any {folder}
  note shape. Desktop buildFolderTree now re-exports it (single source).
- shared/ui/constants.ts — WORKSPACE_ICON_NAMES (Lucide names),
  resolveProjectIconName, PRIORITY_COLOR. Desktop + mobile both consume.

Mobile parity fixes:
- Project icons: ProjectIcon maps the shared Lucide names to
  lucide-react-native (+ react-native-svg) — real Lucide glyphs matching
  desktop, not emoji. (Native rebuild done.)
- Notes hierarchy: collapsible folder TREE (buildFolderTree) with chevrons,
  Folder/FolderOpen icons, nested indent + note rows — mirrors the desktop
  notes-view instead of flat sections.
- Board is interactive: tap a card to expand (description + move controls);
  move cards between columns via moveCardToColumn (plain UPDATE -> capture
  trigger -> sync). Card counts per column.
- Back button: headerBackTitle 'Projects' (was showing '(tabs)').
- PRIORITY_COLOR de-duped to shared.

type-check:all + mobile type-check clean; native build succeeded on device.
Online-only mobile AI via the Rork toolkit. Probing revealed Rork is a simple
{messages} -> {completion} endpoint (NOT OpenAI-compatible, no native function
calling) but reliably returns structured JSON on instruction — so we drive a
prompt-based ReAct tool loop instead of AI-SDK tool().

- rork-client.ts: POST {EXPO_PUBLIC_TOOLKIT_URL||toolkit.rork.com}/text/llm/.
- tools.ts: local tool executors over expo-sqlite (list/search/get notes,
  ensure/append/patch note, list columns, create task, search tasks, list
  projects). Writes go through queries.ts -> capture triggers -> sync, so AI
  edits propagate to desktop (which projects them to .md).
- agent.ts: ReAct loop — system prompt lists tools + a strict JSON protocol,
  parse tool call -> execute -> feed result back -> repeat (cap 6) -> final
  answer. Handles plain-prose + fenced-JSON fallbacks.
- queries.ts: createNote/ensureNote/appendToNote/patchNote/createTask/
  updateTask + client-side collision-free id gen (mirrors desktop nanoid).
- Chat tab: message bubbles (assistant rendered as markdown), live tool-call
  chips, offline hint, composer. Themed.

Rork honors system prompts + returns JSON (verified). Chat is pure JS
(hot-reloads). mobile type-check clean; Metro bundles (10.6MB, all resolve).
Switched mobile AI to Rork's real agent endpoint (verified against the working
../rorkopenai bridge + its EndpointMapping.md):

- rork-client.ts: streamAgentChat() POSTs /agent/chat with the AI SDK v5
  UIMessage shape (parts[] + per-message id, run id, trigger:submit-message) —
  the plain {role,content} shape 500s. Parses the Vercel UI-message SSE stream
  (text-delta, tool-input-available, finish) via expo/fetch (RN's global fetch
  doesn't stream; expo/fetch does).
- tools.ts: each tool now carries a jsonSchema; toolsForAgent() emits the
  { name: { description, jsonSchema } } record the endpoint expects.
- agent.ts: streaming multi-turn tool loop — surface text deltas live, collect
  native tool calls, execute locally (expo-sqlite), merge results back into the
  assistant message as state:output-available, re-run until finish=stop.
- chat.tsx: live token streaming into the assistant bubble + tool chips;
  composer bottom inset (insets.bottom) so the input clears the tab bar.

Also: note ordering now folders-first (matches desktop notes-view).

type-check:all + mobile clean; bundle builds; pure JS (hot-reloads).
…open

The bottom safe-area inset (added so the composer clears the tab bar) was
also applied while the keyboard was up, stacking on top of KeyboardAvoidingView
and leaving empty space above the keyboard. Track keyboard visibility via
Keyboard events and apply insets.bottom only when the keyboard is hidden.
Also drop the hardcoded keyboardVerticalOffset (0 is correct inside Screen).
…roller

Replace KeyboardAvoidingView (janky, left dead space) with the Expo-docs
pattern: useKeyboardHandler drives a reanimated shared value tracking the
keyboard height in lockstep, and an Animated.View spacer below the composer
uses max(keyboardHeight, bottomSafeArea) — frame-perfect avoidance, no dead
space, and still clears the tab bar when closed.

- Add KeyboardProvider at the app root (_layout).
- react-native-keyboard-controller is native → requires a rebuild.
- babel-preset-expo auto-applies the worklets plugin (SDK 57), so the
  'worklet' onMove handler compiles without babel config changes.

mobile type-check clean.
The Directory.pickDirectoryAsync access did NOT survive app relaunch (iOS
grants no persistent scope; expo-file-system creates no security-scoped
bookmark) → 'you don't have permission' writing the oplog after reopening.

Switch to the app's own iCloud ubiquity container, which the app can always
read/write with no picker and which iOS keeps in sync across devices:

- plugins/withICloudContainer.js: adds the iCloud entitlement (CloudDocuments,
  container iCloud.com.gerardslee.cairn) + NSUbiquitousContainers so it shows
  in iCloud Drive as 'Cairn'. Durable across prebuild.
- folder.ts: resolve <container>/Documents/sync via react-native-cloud-store
  (defaultICloudContainerPath) — no picker, auto-persists.
- fs-transport.ts: async iCloud read/write (writeFile/readFile/readDir),
  materialising iCloud placeholders (startDownloadingUbiquitousItem) before read.
- sync.ts: syncNow() is now async; iCloud-availability guard.
- sync.tsx: connect/disconnect UI removed; shows iCloud status + Sync now.

Requires a paid Apple Developer account (iCloud capability) + native rebuild
(react-native-cloud-store pod + entitlement). Desktop points Device Sync at the
same iCloud Drive > Cairn folder. mobile type-check clean; prebuild applied
entitlement + Info.plist + pod.
…ymlink)

Two issues broke '@cairn/shared/notes/folder-tree' after the cloud-store
installs:
1. shared/package.json exports didn't include the notes/ + ui/ subpaths added
   for the desktop-parity work — added them + a ./* wildcard.
2. npm install wiped node_modules/@cairn (the postinstall symlink), so Metro
   couldn't find the package at all.

Made Metro resilient: resolver.resolveRequest now maps @cairn/shared[/sub]
directly to <repo>/shared[/sub].ts, so bundling works even when the postinstall
symlink is missing. Keeps the symlink + watchFolders for hot-reload.

Bundle resolves; type-check:shared + mobile clean.
Returning an absolute {type:sourceFile,filePath} for @cairn/shared made Metro
fail to compute the SHA-1 (it can't hash a file it doesn't track as watched).
Rely on the node_modules/@cairn/shared symlink instead (postinstall + fixed
exports map handle it), which resolves to a path Metro watches via
nodeModulesPaths. Bundle builds clean.
Rebuilt the mobile chat to mirror the desktop chat design (src/components/
chat/chat-panel/ChatMessageBubble.tsx):
- Avatar beside each message: Bot in accent-dim circle (accent border @20%
  via withAlpha), User in surface-3 circle; row reverses for the user.
- Tool-call chips: surface-2 pill + border with a CheckCircle accent icon and
  the prettified tool label (shared prettifyToolLabel), stacked above the
  assistant bubble — replaces the old Wrench chip.
- Bubbles: assistant = surface-2 + border + tight top-left corner; user =
  accent bg + tight top-right corner. Uses the correct surface-2 token (was
  surface).
- Added theme.withAlpha() + shared prettifyToolLabel (both apps identical).

Verified theme tokens already match desktop globals.css exactly (dark + light).
type-check + bundle clean.
…text_pack

Mobile chat gave thin summaries because it only had list/get primitives, while
desktop calls get_active_context + get_project_context_pack (dense, pre-
aggregated). Add the equivalent aggregate tools on mobile, built on the local
DB and matching the desktop read-tools-pure shapes:

- get_cairn_context: all projects (with their columns) + tags, one call.
- get_project_context_pack(project_id): project + columns, noteCount, pinned
  notes (truncated content), open tasks grouped by column (priority/due/desc),
  recent activity — everything needed for a rich project summary in one call.

Listed first in the toolset + the system prompt now steers the agent to start
with get_cairn_context and prefer get_project_context_pack over many small
calls — so mobile summaries match desktop richness.

type-check + bundle clean.
…rn_context

The agent still opened with list_projects (a strict subset of
get_cairn_context, which returns projects + columns + tags). Removed the
list_projects tool entirely and made the system prompt explicit that there is
no separate list-projects tool — begin with get_cairn_context. Forces the
richer orientation call. type-check clean.
…ffect)

Wrap the chat composer input and the search input in a GlassBar that renders
iOS 26 Liquid Glass (expo-glass-effect, already bundled in SDK 57) and falls
back to a solid themed surface on older iOS / Android / reduce-transparency.

- GlassBar guards with isGlassEffectAPIAvailable() + isLiquidGlassAvailable()
  (avoids the iOS-26-beta crash, Expo #40911); exposes glassActive so callers
  drop their opaque bg/border when glass is on.
- More Apple-like: input is a glass pill; composer/search bar backgrounds are
  transparent when glass is active.

Pure JS (module already compiled) — hot-reloads. type-check + bundle clean.
The glass composer felt short/cramped. Bump input minHeight to 48, vertical
padding to 13, radius to 24, text to 16px; send button 44x44 w/ 18px icon;
add composer bottom padding + spacer bottom+6 so it clears the tab bar/keyboard
with breathing room.
…sync badge, card editing

Markdown parity (shared/notes/markdown.ts + MarkdownView):
- [[Wikilinks]] rewritten to cairn://note links, tapped -> resolve title
  (findNoteIdByTitle, workspace-wide) -> navigate to the note. Unresolved =
  no-op.
- ![[embeds]] -> italic placeholder (binary assets out of MVP).
- > [!type] callouts rendered as coloured callout blocks (custom blockquote
  rule) matching the desktop remark pipeline.

Go-to-note from chat: agent now wraps mentioned note titles in [[...]], which
MarkdownView renders as tappable links — free navigation from chat replies.

Sync status badge: SyncStatusBadge in the Projects header shows pending-change
count + state (idle/syncing/offline) and syncs on tap.

Edit tasks: card/[id] detail screen (title, priority chips, column move,
description) via updateTask + moveCardToColumn; board cards now tap to open it
(quick ←/→ move arrows retained). getCard query added.

type-check + bundle clean.
- shared/sync/conflict.ts: pure conflict-copy detection helpers
  (inspectConflict/isConflictCopy/cleanConflictTitle) matching the engine's
  id + title scheme; device ids may contain underscores. Unit-tested.
- mobile: listConflictCopies/conflictCount + resolveConflictKeep{Copy,Original}
  queries; conflict copies excluded from normal note lists/counts/search.
- Conflicts screen (app/conflicts.tsx) shows original vs copy side-by-side with
  keep-either resolution; entry point + count surfaced on the Sync tab.
- TagChips component (colour dot + tinted pill) rendered on note detail, card
  detail, and board cards; tagsForNote/tagsForCard resolve tag_ids -> tags.
- Chat: attach images via expo-image-picker (library or camera) -> native
  UIMessage 'file' parts (data URIs) so /agent/chat receives multimodal input.
  Composer gets an attach button + removable preview strip; user bubbles render
  thumbnails. Permission strings via the expo-image-picker config plugin.
  (Needs a native rebuild for the picker module + iOS permissions.)
- Create notes: note/new screen (folder-aware), reachable from the project
  header + button on the Notes tab.
- Create cards: card/new screen (column + priority), reachable from the project
  header + button and a per-column 'New task' affordance on the board.
- Both use existing createNote/createTask queries; capture triggers publish to
  sync. New routes registered as modals in the root Stack.
ddutchie added 7 commits July 6, 2026 17:10
- AiSettingsSheet: opened from the Chat header gear; Rork/OpenAI toggle
  (shown only when a Rork endpoint is built in), OpenAI base URL / model
  / key fields, keyboard-aware bottom sheet
- ai-config: provider preference (getProviderPref/setProviderPref);
  default model bumped to gpt-4.1-mini
- providers: resolveProvider honours the preference; openai.listModels
  queries GET {base}/models to populate a model picker
- chat: settings gear + "Set up AI" empty-state prompt; re-checks
  hasProvider() when the sheet closes
- agent: inject a system prompt (was dead code) including the current
  date (human + ISO), refreshed each run so it can't go stale — matches
  desktop buildSystemPrompt behaviour
- README: document the provider toggle + in-app config
Use the KeyboardAvoidingView-inside-Modal + flex-end backdrop pattern so
bottom sheets with text inputs lift above the keyboard: flush to the
bottom when closed, hugging the keyboard (no gap) when open. Core RN
<Modal> has no avoidKeyboard prop, and a KeyboardAvoidingView wrapping an
absolutely-positioned sheet floated it too high — flowing the sheet with
justifyContent flex-end fixes both.

- WikilinkPickerSheet: adopt the pattern (AiSettingsSheet already does)
- app.json: android.softwareKeyboardLayoutMode = pan
The @cairn/shared symlink (created by scripts/link-shared.js via
postinstall) can be pruned by `npx expo install <pkg>` without
postinstall re-running, breaking release bundles with 'Unable to resolve
module @cairn/shared/...'.

- metro.config.js: require the link script on every bundle (fires
  regardless of how the build is launched: expo run, EAS, npm run)
- package.json: prestart/preios/preandroid/prebuild + link-shared script
- shared/package.json: explicit exports for every imported subpath
  (notes/*, format/*) so Metro's package-exports resolution is reliable
  beyond the ./* wildcard
The desktop drained sync_pending into sync_oplog on a 5s timer (and on
quit) even with no folder connected, growing the oplog for users who
never sync. backfill() seeds the whole workspace from current table
state on first connect, so the pre-drain was pure waste.

- main.ts: skip drain + full-sync entirely when no sync folder is set
- engine.backfill(): clear sync_pending once seeded (the staged rows are
  subsumed by the full-state backfill) so a later drain adds no redundant
  ops; add a regression test
- changelogs/v2.4.0.md: mobile companion app + desktop Device Sync
- SyncSettings: state explicitly that Device Sync needs the mobile app
  installed (it syncs with your phone, not another desktop) and that the
  app is currently a build-it-yourself companion
Migrate all 7 header-button screens from JS headerRight/headerLeft
render functions to the native Stack.Toolbar API (SF Symbols on iOS,
Material Symbols XML on Android), so headers render as true native bar
items and morph on navigation.

- _layout: wrap Stack in expo-router ThemeProvider (fixes white-flash on
  transitions + iOS 26 liquid-glass flicker in dark mode)
- toolbar-icons.ts: cross-platform icon map, branches on EXPO_OS so Metro
  tree-shakes the unused platform's asset (@expo/material-symbols)
- project/[id]: add button
- card/new, note/new: Save (variant done, disabled when invalid)
- card/[id]: archive menu + Save
- note/[id]: view = pin/tags/delete menu + Edit; edit = Cancel/Save
- chat: clear (hidden when empty) + settings buttons
- projects: SyncStatusBadge via Stack.Toolbar.View (keeps live spinner)
- remove dead JS icon imports + orphaned header styles
- add assets/splashIcon.png (cairn logo, from public/icon.png)
- configure expo-splash-screen with light (#f5f4f1) and dark (#0d0d0d)
  backgrounds matching the app theme; icon contained at 200px
- _layout: preventAutoHideAsync + fade-hide once the DB is ready, so
  the native splash hands off to the first screen with no white flash
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a shared sync/notes library, Electron Device Sync support, and a new Expo mobile app with local storage, iCloud-based sync, chat, markdown rendering, and core project/note/task screens. Desktop utilities are partially moved to shared modules, and several desktop entrypoints are updated to consume the new shared APIs.

Changes

Shared sync, notes, and format library

Layer / File(s) Summary
Shared sync core
shared/sync/*, shared/package.json, tsconfig.shared*.json, vitest.config.ts
Adds HLC, sync engine, transport, conflict helpers, sync schema/contracts, shared exports, and shared test/typecheck coverage.
Shared notes, format, and UI utilities
shared/format/*, shared/notes/*, shared/ui/constants.ts, shared/shared.test.ts, shared/README.md
Adds portable date formatting, folder trees, markdown preprocessing, AI action prompts, syntax palette, text stripping, and UI constants with tests and docs.

Electron Device Sync integration

Layer / File(s) Summary
Electron Device Sync integration
electron/db/*, electron/file-watcher.ts, electron/sync/*, electron/ipc/handlers.ts, electron/main.ts, electron/preload.ts, src/components/settings/*, src/types/index.ts, tsconfig.electron.json, changelogs/v2.4.0.md
Adds sync schema migrations, file-watcher orphan handling, desktop sync orchestration, IPC/preload wiring, background sync scheduling, and the Device Sync settings UI.
Desktop refactor to shared modules
src/components/notes/*, src/lib/*, src/components/chat/chat-panel/MarkdownContent.tsx, src/components/notes/NoteMarkdownPreview.tsx, src/components/notes/note-editor.tsx
Replaces local notes/format/syntax implementations with shared re-exports and hardens markdown rendering with rehypeEscapeUnknownTags.

Cairn mobile app

Layer / File(s) Summary
Project scaffolding, build tooling, and docs
mobile/app.json, mobile/babel.config.js, mobile/eslint.config.js, mobile/metro.config.js, mobile/package.json, mobile/plugins/*, mobile/scripts/*, mobile/tsconfig.json, mobile/.env.example, mobile/.gitignore, mobile/README.md, mobile/eas.json, .github/workflows/release-mobile.yml, scripts/publishupdate.sh, scripts/releasemobile.sh, docs/plans/mobile-app-viability.md, package.json, eslint.config.mjs, .gitignore
Adds Expo config, build tooling, native signing/iCloud plugins, release/update scripts, CI workflow, env/docs, and mobile-specific typecheck/lint setup.
Theme and shared UI primitives
mobile/src/theme.ts, mobile/src/components/{GlassBar,PressableScale,Skeleton,ProjectIcon,TagChips,toolbar-icons,TabScreen,TabStack,ConflictBanner,SyncStatusBadge,UpdateBanner}.tsx
Defines theme tokens and reusable UI primitives.
Local SQLite schema, queries, and chat persistence
mobile/src/db/*
Adds the mobile schema, migrations, query layer, sync adapter, and local chat storage.
iCloud-based sync controller and transport
mobile/src/sync/*
Implements the auto-sync scheduler, iCloud folder resolution, oplog transport, sync round-trip, and sync status hooks.
Local AI chat: providers, agent loop, tools, and settings UI
mobile/src/chat/*, mobile/src/components/AiSettingsSheet.tsx, mobile/app/(tabs)/chat/*
Adds provider selection, OpenAI/Rork streaming providers, AI config persistence, attachments, agent/tool execution, settings, and chat UI.
Markdown, code, and picker rendering components
mobile/src/components/{MarkdownView,MathView,MermaidView,CodeBlock,WebViewRenderer,NoteEditorToolbar,WikilinkPickerSheet,TagPickerSheet,DueDatePickerSheet}.tsx
Implements offline markdown/math/mermaid rendering and editor pickers.
Root layout, tabs, projects, search, notes, and cards screens
mobile/app/_layout.tsx, mobile/app/(tabs)/*, mobile/app/note/*, mobile/app/card/*
Implements app bootstrap and core CRUD screens.
Board, sync status, and conflicts screens
mobile/app/project/[id].tsx, mobile/src/components/DraggableBoard.tsx, mobile/app/sync.tsx, mobile/app/conflicts.tsx
Implements the project board, sync detail page, and conflict resolution UI.

Estimated code review effort: 5 (Critical) | ~180 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ElectronMain as electron/main.ts
  participant DesktopSync as electron/sync/desktop-sync.ts
  participant SyncEngine as shared/sync/engine.ts
  participant SharedFolder
  participant MobileSync as mobile/src/sync/sync.ts
  ElectronMain->>DesktopSync: runFullSync()
  DesktopSync->>SyncEngine: drainPending / backfill
  DesktopSync->>SharedFolder: writeOplogFile()
  MobileSync->>SharedFolder: writeOwnOplog()
  DesktopSync->>SharedFolder: readPeerOplogs()
  MobileSync->>SharedFolder: readPeerOplogs()
  DesktopSync->>SyncEngine: applyRemote(entries)
  MobileSync->>SyncEngine: applyRemote(entries)
Loading

Possibly related PRs

  • ddutchie/cairn#53: It also changes electron/ipc/handlers.ts orchestration around IPC handler registration, which is directly related to the new sync handler registration wiring.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the Cairn mobile companion app and desktop Device Sync.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ddutchie/expomobile

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (14)
electron/db/queries.ts-238-242 (1)

238-242: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the delete-safety doc to point at suppressNextChange().
electron/file-watcher.ts doesn’t define markDeleted(); the guard lives in suppressNextChange() backed by suppressedNoteIds.

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

In `@electron/db/queries.ts` around lines 238 - 242, The delete-safety doc is
pointing to the wrong file-watcher API and should reference the actual guard
used by the watcher. Update the comment near the delete logic in queries.ts to
mention suppressNextChange() and its backing suppressedNoteIds state in
electron/file-watcher.ts, replacing the stale markDeleted() reference so the
documentation matches the current implementation.
docs/plans/mobile-app-viability.md-48-55 (1)

48-55: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the broken table header.

The second table has 4 cells per row, but the header only declares 3 columns, so markdownlint will drop the source references. Add a Source column or move the references out of the table.

🔧 Proposed fix
-| Gap | Consequence if ignored | Required addition |
+| Gap | Consequence if ignored | Required addition | Source |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/mobile-app-viability.md` around lines 48 - 55, The markdown table
in the mobile-app-viability plan has mismatched columns because the header only
defines three fields while the body rows include a source reference column.
Update the table header to include a fourth column for the references, or move
the source references out of the table, so the entries under the schema.ts,
registry.ts, queries.ts, mcp/tools/notes.ts, notes-files.ts, and notes-io.ts
references remain intact and render correctly.

Source: Linters/SAST tools

docs/plans/mobile-app-viability.md-67-83 (1)

67-83: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Label the architecture diagram fence.

The unlabeled fenced block triggers markdownlint MD040. Use text (or mermaid if you want structured rendering) so the block stays lint-clean.

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

In `@docs/plans/mobile-app-viability.md` around lines 67 - 83, The architecture
diagram fenced block is unlabeled, which triggers markdownlint MD040. Update the
fenced block in the mobile-app-viability diagram section to include a language
tag such as text (or mermaid if you want structured rendering) so the block
remains lint-clean while preserving the diagram content.

Source: Linters/SAST tools

mobile/src/chat/providers/rork.ts-42-42 (1)

42-42: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Preserve the base path in the Rork request URL. new URL("/agent/chat", base) drops any path in EXPO_PUBLIC_TOOLKIT_URL (https://host/api → https://host/agent/chat), which breaks the {base}/agent/chat contract. Use a relative segment here, as in openai.ts.

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

In `@mobile/src/chat/providers/rork.ts` at line 42, The Rork request URL is
dropping the configured base path because `new URL("/agent/chat", base)` resets
to the host root, breaking the `{base}/agent/chat` contract. Update the URL
construction in `rork.ts` around the `expoFetch` call so it preserves any path
already present in `EXPO_PUBLIC_TOOLKIT_URL`, following the same
relative-segment approach used in `openai.ts`.
mobile/app/conflicts.tsx-63-76 (1)

63-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Original was deleted" message shown for empty (not deleted) originals.

c.original?.content ?? "(original was deleted)" conflates c.original being null (truly deleted) with c.original.content being null (a live, but empty, note). When the original exists but has no content, this incorrectly tells the user it was deleted.

🐛 Proposed fix
                 <VersionBlock
                   label="This device (original)"
-                  body={c.original?.content ?? "(original was deleted)"}
+                  body={c.original ? (c.original.content ?? "") : "(original was deleted)"}
                   onOpen={c.original ? () => router.push(`/note/${c.original!.id}`) : undefined}
                   styles={styles}
                 />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/app/conflicts.tsx` around lines 63 - 76, The original note display in
conflicts.tsx is using c.original?.content ?? "(original was deleted)", which
mixes up a missing original with an existing note that simply has empty content.
Update the VersionBlock body logic in the conflicts screen so it checks
c.original first and only shows the deleted message when c.original is
null/undefined; if the note exists but content is empty, render an empty/blank
body instead. Use the c.original and VersionBlock references to locate the
affected rendering.
mobile/src/components/MathView.tsx-24-33 (1)

24-33: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Error handler references window.__postError before it's defined.

The KaTeX <script> (lines 24-32) calls window.__postError in its catch, but that function is only defined inside buildAutoHeightScript(), which runs in the next <script> tag (line 33). If katex.render throws synchronously (e.g. bundled KATEX_JS fails to init, or an unexpected non-parse error since throwOnError: false only suppresses LaTeX parse errors), window.__postError is undefined at that point, so the && guard silently swallows the error — onError never fires and the view is left blank instead of falling back.

Swap the script order so the auto-height/error script (which defines __postError) runs before the KaTeX render script.

🐛 Proposed fix
     )}</head><body>
       <div class="cairn-math" id="m"></div>
       <script>${KATEX_JS}</script>
+      <script>${buildAutoHeightScript()}</script>
       <script>
         try {
           katex.render(${safe}, document.getElementById('m'), {
             displayMode: ${display ? "true" : "false"},
             throwOnError: false,
             output: 'html',
           });
         } catch (e) { window.__postError && window.__postError(e && e.message ? e.message : e); }
       </script>
-      <script>${buildAutoHeightScript()}</script>
     </body></html>`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/src/components/MathView.tsx` around lines 24 - 33, The KaTeX render
block in MathView calls window.__postError before it is defined, so synchronous
render failures can be swallowed and onError won’t run. Update MathView so the
script from buildAutoHeightScript() executes before the katex.render script,
ensuring __postError is available when the catch handler runs.
mobile/src/components/NoteEditorToolbar.tsx-181-212 (1)

181-212: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear custom AI input on dismiss.
onDismiss only blurs the body field, but NoteEditorToolbar stays mounted while editing is true, so showCustom/customPrompt can survive a keyboard close and reappear on the next focus. Reset those states in the dismiss path (or when editing ends).

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

In `@mobile/src/components/NoteEditorToolbar.tsx` around lines 181 - 212, The
custom AI prompt state in NoteEditorToolbar can persist after the keyboard is
dismissed because onDismiss only blurs the main field while showCustom and
customPrompt remain set. Update the dismiss flow in NoteEditorToolbar (and/or
the parent editing-close path) to reset showCustom to false and clear
customPrompt so the custom input does not reappear on the next focus.
mobile/src/components/WikilinkPickerSheet.tsx-51-79 (1)

51-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Focus the search input on modal open mobile/src/components/WikilinkPickerSheet.tsx:51-79

autoFocus inside Modal is flaky here; use a ref and call .focus() from onShow so the search field grabs focus every time the sheet opens.

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

In `@mobile/src/components/WikilinkPickerSheet.tsx` around lines 51 - 79, The
search field in WikilinkPickerSheet should be focused reliably when the modal
opens, since relying on autoFocus inside Modal is inconsistent. Add a ref to the
TextInput in WikilinkPickerSheet and use the Modal onShow callback to call
.focus() each time visible becomes true, keeping the existing search input
behavior unchanged.
mobile/src/sync/controller.ts-111-116 (1)

111-116: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

stopAutoSync doesn't prevent an in-flight sync's queued rerun.

If stopAutoSync() is called while a sync is in-flight and pendingRerun is true, the finally block still fires void requestSync("rerun") after teardown, since nothing checks started. Given the doc comment says this teardown path is "mainly for tests / hot-reload cleanliness," a stray sync after "stop" can leak state between test cases.

🔧 Proposed fix
   } finally {
     inFlight = false;
-    if (pendingRerun) {
+    if (pendingRerun && started) {
       pendingRerun = false;
       void requestSync("rerun");
     }
   }

Also applies to: 156-166

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

In `@mobile/src/sync/controller.ts` around lines 111 - 116, The in-flight sync
cleanup in stopAutoSync/requestSync still schedules a queued rerun after
teardown because the finally block unconditionally calls requestSync("rerun")
when pendingRerun is set. Update the rerun path in controller.ts to check that
syncing is still started before clearing pendingRerun and invoking requestSync,
so stopAutoSync can fully suppress any queued rerun. Use the existing started
flag and the requestSync / stopAutoSync flow to keep the teardown path from
triggering work after shutdown.
shared/notes/text.ts-11-17 (1)

11-17: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

stripMarkdown can merge words together when markdown syntax abuts text.

Matched characters are replaced with an empty string before whitespace collapsing. This works when markdown tokens are already surrounded by spaces (as in the test fixtures), but for constructs without surrounding whitespace — e.g. a link [text](url) — removing [, ], (, ) directly concatenates text and url into a single run with no separating space, degrading search-index/preview quality.

Replacing matched characters with a space instead of an empty string fixes this while preserving all existing test expectations (traced by hand against shared/shared.test.ts's stripMarkdown cases).

🐛 Suggested fix
 export function stripMarkdown(md: string): string {
   if (!md) return "";
   return md
-    .replace(/[#*_`>[\]()!-]/g, "")
+    .replace(/[#*_`>[\]()!-]/g, " ")
     .replace(/\s+/g, " ")
     .trim();
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/notes/text.ts` around lines 11 - 17, stripMarkdown currently removes
markdown punctuation in a way that can concatenate adjacent words, especially
for patterns like links. Update stripMarkdown in text.ts to replace matched
markdown characters with a space instead of deleting them, while keeping the
existing whitespace-collapsing and trim behavior. This should preserve the
current stripMarkdown expectations in shared.test.ts and prevent text runs from
merging when syntax abuts content.
shared/format/date.ts-25-42 (1)

25-42: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use UTC calendar-day math here. Dividing the local-midnight delta by 86400000 is DST-sensitive; on a 23-hour day it can return 0 for the next calendar day. Compute the difference from UTC midnights or a calendar-day helper instead.

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

In `@shared/format/date.ts` around lines 25 - 42, formatDateCompact currently
computes day differences using local-midnight timestamps divided by 86400000,
which can be DST-sensitive. Update the logic in formatDateCompact to compare UTC
calendar days instead, either by normalizing both dates to UTC midnight before
subtracting or by using a calendar-day helper, while keeping the existing
Today/Yesterday/d ago formatting behavior.
shared/format/date.ts-65-76 (1)

65-76: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle yyyy-MM-dd due dates without new Date(...). dueDate is stored as a date key, so parsing it as a timestamp can shift the day in negative UTC offsets and misclassify overdue/today/upcoming. Compare the date string directly here, or convert via UTC date parts; formatDate(dueDate) has the same issue where this value is displayed.

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

In `@shared/format/date.ts` around lines 65 - 76, The date comparison in
getDueDateStatus is parsing a yyyy-MM-dd key with new Date, which can shift the
day by timezone and misclassify the status. Update getDueDateStatus to compare
the date string safely without relying on local timestamp parsing, or convert
the input using UTC date parts before comparing to today; also review formatDate
for the same parsing pattern and make it use the same safe date handling.
shared/sync/hlc.ts-36-43 (1)

36-43: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

decodeHlc has no validation against malformed stamps.

If a stamp string is malformed (e.g. a partially-corrupted line surviving JSON parsing in a peer's .ndjson oplog file, or any future encoding drift), parseInt(p, 16)/parseInt(c, 16) silently return NaN. compareHlc would then propagate NaN (V8 treats NaN comparator results as equal), silently breaking the total order that the whole reconcile/compaction pipeline depends on for correctness — a bad outcome for a system whose stated goal is "zero data loss." readPeerOplogs (shared/sync/transport.ts) already guards against corrupt lines, but nothing guards against corrupt fields within an otherwise-valid JSON line.

Consider validating in decodeHlc and failing loudly (letting the caller decide whether to skip or abort that entry) rather than degrading silently.

🛡️ Proposed validation
 export function decodeHlc(stamp: string): HlcParts {
   const [p, c, ...rest] = stamp.split(":");
+  const physical = parseInt(p, 16);
+  const counter = parseInt(c, 16);
+  if (!p || !c || Number.isNaN(physical) || Number.isNaN(counter)) {
+    throw new Error(`Invalid HLC stamp: ${stamp}`);
+  }
   return {
-    physical: parseInt(p, 16),
-    counter: parseInt(c, 16),
+    physical,
+    counter,
     deviceId: rest.join(":"),
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/sync/hlc.ts` around lines 36 - 43, decodeHlc currently accepts
malformed stamp fields and can return NaN, which breaks Hlc ordering silently.
Add explicit validation inside decodeHlc to ensure the stamp splits into valid
hex physical/counter parts before parsing, and throw a clear error on malformed
input instead of returning invalid HlcParts. Keep the fix localized to decodeHlc
and ensure compareHlc only ever receives validated values.
shared/sync/engine.ts-582-593 (1)

582-593: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Let SQLite apply defaults in tombstone shells (shared/sync/engine.ts:582-593)

PRAGMA table_info(...).dflt_value is raw SQL text, so DEFAULT 'active' gets inserted as the literal string "'active'". Skip columns that already have a real default and only synthesize placeholders for NOT NULL columns without one; otherwise the tombstone row stores corrupted values.

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

In `@shared/sync/engine.ts` around lines 582 - 593, The tombstone placeholder
logic in insertTombstoneShell is currently copying db_cols_notnull defaults into
the row as raw SQL text, which can corrupt values when the default is a quoted
literal. Update insertTombstoneShell to let SQLite apply real defaults by
skipping NOT NULL columns that already have a dflt_value from db_cols_notnull,
and only synthesizing placeholder values for NOT NULL columns with no default
before calling writeRow.
🧹 Nitpick comments (21)
electron/main.ts (2)

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

require() + manually-typed consts bypass compile-time checking of the sync module contract.

desktopSync, writeNoteFile/deleteNoteFile, suppressNextChange, getProjectName, and getNoteById are all pulled in via require() with hand-written type annotations rather than static import. This likely stems from electron/sync/**/*.ts being excluded from tsconfig.electron.json (see that file's review), but for the other requires (./notes-files, ./file-watcher, ./ipc/result-helpers, ./db/queries) which are NOT excluded, static imports would restore real type-checking against the actual exported signatures instead of relying on manually maintained type strings that can silently drift out of sync.

♻️ Suggested direction for the non-excluded requires
-  // eslint-disable-next-line `@typescript-eslint/no-require-imports`
-  const { writeNoteFile, deleteNoteFile } = require("./notes-files");
-  // eslint-disable-next-line `@typescript-eslint/no-require-imports`
-  const { suppressNextChange } = require("./file-watcher");
-  // eslint-disable-next-line `@typescript-eslint/no-require-imports`
-  const { getProjectName } = require("./ipc/result-helpers");
-  // eslint-disable-next-line `@typescript-eslint/no-require-imports`
-  const { getNoteById } = require("./db/queries");
+  import { writeNoteFile, deleteNoteFile } from "./notes-files";
+  import { suppressNextChange } from "./file-watcher";
+  import { getProjectName } from "./ipc/result-helpers";
+  import { getNoteById } from "./db/queries";

(Move these to top-level imports if there's no circular-dependency reason for the lazy require pattern.)

Also applies to: 438-445

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

In `@electron/main.ts` around lines 428 - 433, Replace the manually typed
require-based bindings in main.ts with static imports for the non-excluded
modules so TypeScript checks the वास्तविक exported contracts instead of relying
on hand-written signatures. Update the bindings around desktopSync,
writeNoteFile/deleteNoteFile, suppressNextChange, getProjectName, and
getNoteById to use the actual module exports from sync/desktop-sync,
notes-files, file-watcher, ipc/result-helpers, and db/queries where possible,
keeping lazy require only if there is a proven circular-dependency need. Ensure
the existing identifiers still map cleanly to the imported symbols after the
refactor.

481-503: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider extracting the background-sync wiring into a helper.

Timers, powerMonitor, focus, and before-quit wiring are all inlined in this one block, flagged as high complexity in the line-range summary. Extracting into a small wireBackgroundSync(ctx, win, desktopSync) helper (in electron/sync/ or a new module) would isolate lifecycle bookkeeping from the rest of window setup and make the listener-cleanup story easier to audit.

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

In `@electron/main.ts` around lines 481 - 503, The background-sync setup is too
complex in the current window initialization block because timers and lifecycle
listeners are all wired inline. Move the drain interval, full sync interval, and
the `powerMonitor`/`focus`/`before-quit` subscriptions out of the main flow into
a helper such as `wireBackgroundSync(ctx, win, desktopSync)` so the lifecycle
bookkeeping is isolated. Keep the helper responsible for registering and
cleaning up the listeners/intervals, and call it from the existing setup code in
`electron/main.ts`.
electron/db/schema.ts (1)

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

Deduplicate the SYNCABLE list to prevent v25/v26 divergence.

The same table list is declared independently in v25 (lines 605-608) and v26 (lines 660-663). If a future syncable table is added to the column/tables migration but not the trigger migration (or vice-versa), rows would gain hlc/deleted_at without capture triggers (or triggers referencing columns that don't exist), silently breaking sync for that entity. Hoist it to a single module-level constant (or import the shared SYNCABLE_TABLES) and reference it from both.

Note: the OpenGrep command-injection.exec-js hints on lines 619-620/665-684 are false positives — these are db.exec SQL statements interpolating a hardcoded constant list, not shell commands.

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

In `@electron/db/schema.ts` around lines 605 - 608, The SYNCABLE table list is
duplicated between the v25 and v26 schema logic, which can cause them to drift
and break sync for new tables. Hoist the shared table names into a single
module-level constant (or reuse the existing shared SYNCABLE_TABLES) and
reference it from both the migration/trigger paths in schema.ts, especially the
sections that define the SYNCABLE array for the column/table migration and
trigger migration. Keep the SQL exec calls unchanged; only centralize the list
so both versions stay in sync.

Source: Linters/SAST tools

mobile/app/(tabs)/chat/index.tsx (1)

89-100: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Minor: image context dropped on history restore. When rebuilding conversation.current, user turns are re-added via userMessage(h.content) without their stored images, so the agent loses image context across relaunches even though the UI bubbles keep them. If restoring image context matters, pass the attachments back through. Otherwise this is an acceptable tradeoff to keep the payload small.

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

In `@mobile/app/`(tabs)/chat/index.tsx around lines 89 - 100, The chat history
restore logic in useEffect is dropping image context when rebuilding
conversation.current. Update the history replay in chat/index.tsx so user turns
restored from loadChatHistory() pass h.images back into userMessage along with
h.content, matching the data already preserved in setMessages. Keep the
assistantMessage path unchanged and ensure the restored conversation state stays
consistent with the UI bubbles.
mobile/src/components/AiSettingsSheet.tsx (1)

103-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale comment. The comment claims the key is only rewritten "if the field changed", but setOpenAIApiKey(apiKey) is called unconditionally. Either drop the comment or implement the change-detection it describes (e.g., skip write when apiKey equals the loaded value).

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

In `@mobile/src/components/AiSettingsSheet.tsx` around lines 103 - 105, The
comment around setOpenAIApiKey(apiKey) in AiSettingsSheet is stale because the
write happens unconditionally. Update the logic in the save handler to either
actually compare apiKey against the loaded value before calling setOpenAIApiKey,
or remove the comment if unconditional writes are intended. Use the existing
loaded-value state in AiSettingsSheet to keep the behavior and comment
consistent.
mobile/src/components/TagPickerSheet.tsx (1)

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

Prefer the withAlpha helper over a hardcoded rgba() backdrop.

The file already imports withAlpha and uses it elsewhere; the modal backdrop is the only spot still hardcoding an rgba string.

♻️ Proposed fix
-    backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)" },
+    backdrop: { flex: 1, backgroundColor: withAlpha("`#000000`", 0.4) },

As per coding guidelines, **/*.{css,scss,ts,tsx}: "Use color-mix(in srgb, var(--token) X%, transparent) for alpha variants and never hardcode rgba()" — applying the intent via the project's own withAlpha alpha-mixing helper since RN has no CSS variables/color-mix.

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

In `@mobile/src/components/TagPickerSheet.tsx` at line 95, The modal backdrop in
TagPickerSheet still hardcodes an rgba string instead of using the existing
withAlpha helper. Update the backdrop style in the TagPickerSheet component to
build the semi-transparent color through withAlpha, matching the other color
usage in this file and keeping the backdrop alpha consistent with the project’s
color handling conventions.

Source: Coding guidelines

mobile/src/components/DueDatePickerSheet.tsx (1)

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

Same hardcoded rgba() backdrop as TagPickerSheet.tsx.

♻️ Proposed fix
-import { useTheme, elevation, type Theme } from "`@/theme`";
+import { useTheme, withAlpha, elevation, type Theme } from "`@/theme`";
@@
-    backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)" },
+    backdrop: { flex: 1, backgroundColor: withAlpha("`#000000`", 0.4) },

As per coding guidelines, **/*.{css,scss,ts,tsx}: "never hardcode rgba()" — using the project's withAlpha helper keeps this consistent with the rest of the codebase.

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

In `@mobile/src/components/DueDatePickerSheet.tsx` at line 75, The backdrop style
in DueDatePickerSheet uses a hardcoded rgba() value, which should be replaced to
match the codebase guideline. Update the backdrop color to use the shared
withAlpha helper instead of the literal rgba string, and keep the change within
the backdrop style object in DueDatePickerSheet so it stays consistent with
TagPickerSheet and other sheet components.

Source: Coding guidelines

mobile/src/components/WebViewRenderer.tsx (1)

48-59: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Navigation is not actually blocked despite the "hardened against navigation" comment.

originWhitelist={["*"]} is required for static source={{ html }} content, but on its own it does not prevent in-webview navigation — it just allows loading local HTML. There is no onShouldStartLoadWithRequest handler here, so any link/navigation triggered from within the rendered content (now or in future consumers of this shared renderer) would not actually be intercepted, contradicting the "hardened against navigation" comment.

Since content today is generated from JSON.stringify-escaped values (math/mermaid source), risk is currently low, but the comment overstates the real protection. Consider adding a handler that rejects any navigation away from the initial about:blank/inline document.

🛡️ Suggested hardening
       <WebView
         ref={ref}
         originWhitelist={["*"]}
         source={{ html }}
         style={[styles.web, { opacity: loading ? 0 : 1 }]}
         scrollEnabled={false}
         showsVerticalScrollIndicator={false}
         showsHorizontalScrollIndicator={false}
         onMessage={onMessage}
+        onShouldStartLoadWithRequest={(req) => req.url === "about:blank"}
         // Static, bundled content only — hardened against navigation.
         javaScriptEnabled
         setSupportMultipleWindows={false}
       />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/src/components/WebViewRenderer.tsx` around lines 48 - 59, The WebView
in WebViewRenderer is not actually hardened against navigation because
originWhitelist only permits the inline HTML source and does not block
in-webview navigations. Add an onShouldStartLoadWithRequest handler to the
WebView that rejects any request other than the initial inline/about:blank load,
and keep the existing static html rendering and onMessage behavior unchanged.
Update the surrounding comment so it accurately reflects the protection provided
by WebViewRenderer.
mobile/src/components/NoteEditorToolbar.tsx (2)

179-181: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

styles is rebuilt on every render instead of memoized.

ScrollAffordanceRow in this same file memoizes makeStyles(t) via useMemo (line 95), and WebViewRenderer/WikilinkPickerSheet do the same. Here makeStyles(t) runs unmemoized on every render of NoteEditorToolbar, including every keystroke in the custom-prompt TextInput (customPrompt state changes trigger a re-render of the whole component), needlessly recreating the StyleSheet object each time.

♻️ Proposed fix
-  const styles = makeStyles(t);
+  const styles = useMemo(() => makeStyles(t), [t]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/src/components/NoteEditorToolbar.tsx` around lines 179 - 181,
`NoteEditorToolbar` is recreating `makeStyles(t)` on every render, including
keystrokes from `customPrompt`, instead of memoizing the style object. Update
the `styles` initialization in `NoteEditorToolbar` to use the same `useMemo`
pattern already used by `ScrollAffordanceRow`, `WebViewRenderer`, and
`WikilinkPickerSheet`, so `makeStyles(t)` only runs when the theme changes.

231-243: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Icon-only buttons have no accessibility labels.

The AI action chips and formatting buttons render only an Icon (and label text for AI chips is present, but the formatting-row Pressables at lines 280-291 are icon-only with no accessibilityLabel/accessibilityRole). Screen-reader users can't tell what each formatting button (bold, italic, h1, wikilink, etc.) does.

♿ Proposed fix (formatting buttons)
                 <Pressable
                   key={id}
                   disabled={disabled}
                   onPress={() => onFormat(id)}
                   style={[styles.fmtBtn, disabled && styles.disabled]}
                   hitSlop={4}
+                  accessibilityRole="button"
+                  accessibilityLabel={id}
                 >

Also applies to: 280-291

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

In `@mobile/src/components/NoteEditorToolbar.tsx` around lines 231 - 243, The
formatting-row Pressable controls in NoteEditorToolbar are icon-only and lack
screen-reader labels/roles, so they need explicit accessibility metadata. Update
the Pressable buttons in the formatting button section (and verify the AI chip
buttons in the same component) to provide an accessibilityRole and a descriptive
accessibilityLabel derived from each action name, so users can identify bold,
italic, h1, wikilink, and similar actions without relying on the icon. Make sure
the labels are tied to the existing button identifiers used in
NoteEditorToolbar/handleAI so each control remains discoverable even if the icon
changes.
mobile/src/db/schema.ts (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial

No forward migration path for schema changes.

CREATE TABLE IF NOT EXISTS only creates tables that don't yet exist; it won't add new columns to already-installed devices when the mobile schema evolves later (desktop already needed explicit v25/v26 migrations for its own sync columns). Once this ships, a similar versioned-migration mechanism (e.g., a schema_version row in sync_state + ALTER TABLE steps) will be needed for the mobile DB too.

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

In `@mobile/src/db/schema.ts` around lines 19 - 22, The mobile schema setup in
MOBILE_SCHEMA_SQL currently only relies on CREATE TABLE IF NOT EXISTS, so
existing installs will never pick up added columns when the schema evolves. Add
a versioned migration path around the schema initialization in schema.ts, using
a persisted version marker such as a schema_version entry in sync_state and
applying explicit ALTER TABLE migrations for future changes. Make the migration
logic part of the mobile DB initialization flow so existing devices can be
upgraded safely as the schema changes over time.
mobile/src/db/chat-store.ts (1)

38-62: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider capping chat history growth and moving image data off SQLite rows.

images are stored as full data URIs inline in the chat_local table, and loadChatHistory loads the entire unbounded history on every mount. Over long-term use this can bloat the local DB and slow reads. Consider storing image files on the filesystem (keeping only a path/reference in the row) and/or paginating loadChatHistory.

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

In `@mobile/src/db/chat-store.ts` around lines 38 - 62, The chat history loading
and persistence in loadChatHistory and saveChatMessage currently keep full image
data URIs inline and read the entire unbounded table each time, which will bloat
the local DB and slow startup. Update StoredMessage handling so images are
stored as filesystem references or paths instead of raw data, and change
loadChatHistory to fetch only a bounded/paginated set of recent messages rather
than all rows at once.
shared/README.md (1)

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

"Contents" section is stale — missing format/, notes/, and ui/.

This cohort adds format/date.ts, several notes/* modules, and ui/constants.ts, but the README's Contents section only documents sync/. Worth updating so new consumers can discover the full shared surface.

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

In `@shared/README.md` around lines 24 - 36, The README Contents section is out of
date because it only documents the sync area and omits the newly added format/,
notes/, and ui/ modules. Update the Contents list in shared/README.md to include
the new shared surface, referencing the existing section structure under the
contents header and the module names such as format/date.ts, the notes/*
modules, and ui/constants.ts so readers can discover them.
tsconfig.shared.json (1)

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

Node types are applied across both the portable shared/** library and Node-only Electron paths.

This single tsconfig type-checks shared/**/*.ts (documented as platform-neutral, consumed by both Electron and the Expo mobile app) together with electron/db/**/*.ts and electron/sync/**/*.ts (Node-only) under "types": ["node"]. This lets Node globals (process, Buffer, etc.) type-check inside shared/** files even though they won't exist at runtime on React Native/mobile.

Consider splitting into a portable tsconfig.shared.json (no "types": ["node"], include: ["shared/**/*.ts"]) and a separate config for the Electron-only sync/db paths that legitimately need Node types.

Also applies to: 15-15

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

In `@tsconfig.shared.json` at line 13, The shared TypeScript config is currently
applying Node types to both portable shared code and Electron-only code, which
leaks Node globals into platform-neutral modules. Split the current setup so the
portable config used by shared code no longer includes "types": ["node"] and
only covers shared/**/*.ts, then create a separate Electron-only config for the
db/sync paths that still needs Node typings. Update the relevant tsconfig files
so shared/** stays platform-neutral while electron/db/** and electron/sync/**
continue to type-check with Node types.
mobile/app/card/new.tsx (2)

20-20: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

listColumns(project) re-queried on every keystroke.

Same pattern as card/[id].tsx: columns is recomputed on every render since title/description/priority are controlled state, causing a redundant synchronous SQLite query per keystroke.

🛠️ Proposed fix
-  const columns: ColumnRow[] = project ? listColumns(project) : [];
+  const columns: ColumnRow[] = useMemo(() => (project ? listColumns(project) : []), [project]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/app/card/new.tsx` at line 20, The columns list is being recomputed on
every render in the new-card screen, which triggers a redundant SQLite query
whenever controlled inputs change. Update the `new.tsx` component to memoize or
otherwise cache the result of `listColumns(project)` so it only recalculates
when `project` changes, following the same approach used in `card/[id].tsx`;
keep the fix localized around the `columns` assignment and any related
render-time derived state.

61-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded "#fff" instead of theme token.

Same as in card/[id].tsx — the column chip below (Line 81) uses t.accentFg for the equivalent text-on-accent case; prefer that token here for consistency.

♻️ Proposed fix
-              <Text style={[styles.chipText, { color: priority === p ? "`#fff`" : t.textSecondary }]}>{p}</Text>
+              <Text style={[styles.chipText, { color: priority === p ? t.accentFg : t.textSecondary }]}>{p}</Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/app/card/new.tsx` around lines 61 - 67, The priority chip in the card
creation screen is using a hardcoded white text color instead of the theme token
used elsewhere. Update the Text styling inside the Pressable map in new.tsx to
use the same accent foreground token pattern as card/[id].tsx (the equivalent
text-on-accent case) so the chip text follows theme consistency and avoids
hardcoded color values.
mobile/app/card/[id].tsx (1)

108-111: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hardcoded "#fff" instead of theme token.

The active-priority chip text uses a raw "#fff" while the column chip a few lines below (Line 125) uses t.accentFg for the same "text on accent-colored background" purpose. Prefer the theme token for consistency (dark/light/contrast handling).

♻️ Proposed fix
-              <Text style={[styles.priorityText, { color: priority === p ? "`#fff`" : t.textSecondary }]}>{p}</Text>
+              <Text style={[styles.priorityText, { color: priority === p ? t.accentFg : t.textSecondary }]}>{p}</Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mobile/app/card/`[id].tsx around lines 108 - 111, The active priority chip
text in the priority selector is using a hardcoded white color instead of the
theme token. Update the Text styling in the priority chip render path to use the
same theme-based foreground token used by the column chip (for example, the
theme’s accent foreground value) so the color stays consistent across themes and
contrast modes.
mobile/app/(tabs)/search/index.tsx (1)

40-48: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider debouncing search-as-you-type.

onChange/switchScope run a synchronous SQLite query on every keystroke via onChangeText. With local SQLite and a LIMIT 50 this is likely fine in practice, but a small debounce would avoid redundant queries on fast typing.

Also applies to: 67-74

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

In `@mobile/app/`(tabs)/search/index.tsx around lines 40 - 48, The search handlers
in the search screen are firing a SQLite query on every keystroke, so add a
small debounce around the query trigger in the relevant search flow. Update the
`onChange` and `switchScope` logic in the search component so they only call
`run` after input stabilizes, using the existing `query`, `scope`, and `run`
symbols to locate the search-as-you-type path. Ensure the debounce is applied
consistently where the same pattern appears elsewhere in the search screen so
rapid typing or scope changes do not launch redundant synchronous queries.
shared/sync/engine.ts (1)

192-220: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoidable extra SELECT per row in put, drainPending, and backfill.

All three methods update a row's hlc column via an UPDATE/INSERT, then immediately call readRow(...) again just to build the oplog payload, when the previously-fetched row object (merged with the new hlc) already has everything needed in memory. This doubles the DB round-trips on every drained change and on every backfilled row — drainPending runs on the periodic background sync tick, so this is on a recurring hot path, not just the one-time backfill.

♻️ Example fix for `drainPending` (same pattern applies to `put`/`backfill`)
         } else {
           this.db.prepare(`UPDATE ${entity} SET hlc = ? WHERE id = ?`).run(stamp, entity_id);
-          this.appendOplog({ hlc: stamp, origin: this.deviceId, entity, entity_id, op: "put", payload: readRow(this.db, entity, entity_id) ?? null });
+          this.appendOplog({ hlc: stamp, origin: this.deviceId, entity, entity_id, op: "put", payload: { ...row, hlc: stamp } });
         }

Also applies to: 255-260, 294-301

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

In `@shared/sync/engine.ts` around lines 192 - 220, The oplog payload creation in
the row publish paths is doing an extra read after the row was already fetched
and updated in memory. In the affected methods like drainPending, put, and
backfill in the sync engine, reuse the existing row object (merged with the new
hlc value) when calling appendOplog instead of invoking readRow again. Keep the
existing row variable/shape as the source of truth for the payload so each
UPDATE/INSERT only needs one database round-trip.
shared/sync/transport.ts (2)

19-21: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Unsanitized deviceId embedded in filename.

oplogFileName interpolates deviceId directly into a path segment with no validation. If deviceId ever contains path separators or .., writeOplogFile/readPeerOplogs could write/read outside syncFolder. Given deviceId is presumably generated internally (UUID-like), exploitability is low today, but nothing here enforces that invariant at the boundary where this transport reads/writes files.

🛡️ Proposed guard
 function oplogFileName(deviceId: string): string {
+  if (!/^[A-Za-z0-9_-]+$/.test(deviceId)) {
+    throw new Error(`Invalid deviceId for oplog filename: ${deviceId}`);
+  }
   return `oplog-${deviceId}.ndjson`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/sync/transport.ts` around lines 19 - 21, The filename helper
oplogFileName currently trusts deviceId and can build unsafe paths; add
validation or sanitization at the boundary before it is used by writeOplogFile
and readPeerOplogs. Update oplogFileName to reject any deviceId containing path
separators, traversal tokens, or other non-identifier characters, or normalize
it to a safe form, and ensure callers in the transport flow cannot pass an
untrusted raw value.

42-51: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

No shape validation on parsed remote oplog entries.

JSON.parse(trimmed) as OplogEntry trusts the cast; a peer running a mismatched schema version, or a partially-written line that happens to parse as valid JSON but with a different shape, would pass through to applyRemote and could cause downstream failures in the sync engine rather than being safely skipped here like the malformed-JSON case already is.

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

In `@shared/sync/transport.ts` around lines 42 - 51, The parsing loop in
transport.ts only catches malformed JSON, but JSON.parse(trimmed) is still
blindly cast to OplogEntry and can let mismatched or partial shapes reach
applyRemote. Add shape validation immediately after parsing in the existing
line-processing logic so only entries that match the expected OplogEntry
structure are pushed to out, and skip anything invalid the same way the current
catch block skips bad JSON.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f5149ac-e020-4eae-8bfa-7e68c058bb61

📥 Commits

Reviewing files that changed from the base of the PR and between 397d1e9 and ca7692c.

⛔ Files ignored due to path filters (3)
  • mobile/assets/icon.png is excluded by !**/*.png
  • mobile/assets/splashIcon.png is excluded by !**/*.png
  • mobile/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (123)
  • .gitignore
  • changelogs/v2.4.0.md
  • docs/plans/mobile-app-viability.md
  • electron/db/queries.test.ts
  • electron/db/queries.ts
  • electron/db/schema.ts
  • electron/file-watcher.ts
  • electron/ipc/handlers.ts
  • electron/main.ts
  • electron/preload.ts
  • electron/sync/desktop-sync.ts
  • electron/sync/sync-handlers.ts
  • eslint.config.mjs
  • mobile/.env.example
  • mobile/.gitignore
  • mobile/README.md
  • mobile/app.json
  • mobile/app/(tabs)/_layout.tsx
  • mobile/app/(tabs)/chat/_layout.tsx
  • mobile/app/(tabs)/chat/index.tsx
  • mobile/app/(tabs)/index/_layout.tsx
  • mobile/app/(tabs)/index/index.tsx
  • mobile/app/(tabs)/search/_layout.tsx
  • mobile/app/(tabs)/search/index.tsx
  • mobile/app/_layout.tsx
  • mobile/app/card/[id].tsx
  • mobile/app/card/new.tsx
  • mobile/app/conflicts.tsx
  • mobile/app/note/[id].tsx
  • mobile/app/note/new.tsx
  • mobile/app/project/[id].tsx
  • mobile/app/sync.tsx
  • mobile/babel.config.js
  • mobile/eslint.config.js
  • mobile/metro.config.js
  • mobile/package.json
  • mobile/plugins/withAppleSigning.js
  • mobile/plugins/withICloudContainer.js
  • mobile/scripts/gen-webview-assets.js
  • mobile/scripts/link-shared.js
  • mobile/src/chat/agent.ts
  • mobile/src/chat/ai-config.ts
  • mobile/src/chat/attachments.ts
  • mobile/src/chat/providers/index.ts
  • mobile/src/chat/providers/openai.ts
  • mobile/src/chat/providers/rork.ts
  • mobile/src/chat/providers/types.ts
  • mobile/src/chat/tools.ts
  • mobile/src/components/AiSettingsSheet.tsx
  • mobile/src/components/CodeBlock.tsx
  • mobile/src/components/ConflictBanner.tsx
  • mobile/src/components/DraggableBoard.tsx
  • mobile/src/components/DueDatePickerSheet.tsx
  • mobile/src/components/GlassBar.tsx
  • mobile/src/components/MarkdownView.tsx
  • mobile/src/components/MathView.tsx
  • mobile/src/components/MermaidView.tsx
  • mobile/src/components/NoteEditorToolbar.tsx
  • mobile/src/components/PressableScale.tsx
  • mobile/src/components/ProjectIcon.tsx
  • mobile/src/components/Skeleton.tsx
  • mobile/src/components/SyncStatusBadge.tsx
  • mobile/src/components/TabScreen.tsx
  • mobile/src/components/TabStack.tsx
  • mobile/src/components/TagChips.tsx
  • mobile/src/components/TagPickerSheet.tsx
  • mobile/src/components/WebViewRenderer.tsx
  • mobile/src/components/WikilinkPickerSheet.tsx
  • mobile/src/components/toolbar-icons.ts
  • mobile/src/db/chat-store.ts
  • mobile/src/db/expo-sync-db.ts
  • mobile/src/db/index.ts
  • mobile/src/db/queries.ts
  • mobile/src/db/schema.ts
  • mobile/src/sync/controller.ts
  • mobile/src/sync/folder.ts
  • mobile/src/sync/fs-transport.ts
  • mobile/src/sync/sync.ts
  • mobile/src/sync/useSyncStatus.ts
  • mobile/src/sync/write-signal.ts
  • mobile/src/theme.ts
  • mobile/src/types.d.ts
  • mobile/src/webview-assets/katex-assets.ts
  • mobile/src/webview-assets/mermaid-assets.ts
  • mobile/tsconfig.json
  • package.json
  • shared/README.md
  • shared/format/date.ts
  • shared/format/index.ts
  • shared/notes/ai-actions.ts
  • shared/notes/folder-tree.ts
  • shared/notes/format.ts
  • shared/notes/markdown.ts
  • shared/notes/syntax-palette.ts
  • shared/notes/text.ts
  • shared/package.json
  • shared/shared.test.ts
  • shared/sync/conflict.test.ts
  • shared/sync/conflict.ts
  • shared/sync/db-adapter.ts
  • shared/sync/engine.test.ts
  • shared/sync/engine.ts
  • shared/sync/hlc.ts
  • shared/sync/index.ts
  • shared/sync/schema.ts
  • shared/sync/transport.ts
  • shared/ui/constants.ts
  • src/components/chat/chat-panel/MarkdownContent.tsx
  • src/components/notes/NoteMarkdownPreview.tsx
  • src/components/notes/ai-text-toolbar.tsx
  • src/components/notes/note-editor-utils.ts
  • src/components/notes/note-editor.tsx
  • src/components/notes/notes-view/buildFolderTree.ts
  • src/components/settings/SyncSettings.tsx
  • src/components/settings/settings-view.tsx
  • src/lib/markdown/pipeline.tsx
  • src/lib/syntax-palette.ts
  • src/lib/utils.ts
  • src/types/index.ts
  • tsconfig.electron.json
  • tsconfig.json
  • tsconfig.shared.json
  • vitest.config.ts

Comment thread electron/sync/desktop-sync.ts Outdated
Comment thread mobile/app/_layout.tsx
Comment thread mobile/app/card/[id].tsx Outdated
Comment thread mobile/app/conflicts.tsx
Comment thread mobile/app/note/[id].tsx
Comment thread shared/notes/folder-tree.ts
Comment thread shared/notes/format.ts
Comment thread shared/sync/engine.ts
Comment thread src/components/settings/SyncSettings.tsx Outdated
Comment thread src/lib/markdown/pipeline.tsx
ddutchie added 8 commits July 6, 2026 19:48
- release-mobile.yml: tag (mobile-v*) / manual workflow → EAS Build (iOS,
  production) + EAS Submit to TestFlight via EXPO_TOKEN
- eas.json: production + preview build profiles (autoIncrement, remote
  appVersionSource), submit profile, update channels
- scripts/releasemobile.sh: patch|minor|major (or explicit) version bump
  mirroring desktop release.sh, with a mobile pre-flight gate
  (type-check + lint) before tagging
- app.json: EAS projectId/owner, updates url + runtimeVersion (appVersion),
  dedupe RECORD_AUDIO permission
- align deps to Expo SDK 57 (expo/router/constants patches,
  gesture-handler ~2.32.0), add react-native-worklets peer dep,
  drop redundant metro unstable_enableSymlinks, exclude
  react-native-cloud-store from doctor directory check
- expo-doctor: 20/20 checks pass
- useAppUpdates: check on cold launch + on foreground (AppState),
  download in the background, expose isUpdatePending + reload(); no-op
  in dev (Updates.isEnabled)
- UpdateBanner: themed, safe-area-aware 'update ready — Restart' banner,
  mounted in the root layout; renders nothing until an update is staged
- scripts/publishupdate.sh: eas update wrapper (JS/asset only; native
  changes still go through releasemobile.sh)
- README: document the OTA flow + the runtimeVersion caveat
- MermaidView/MathView: escape </ in embedded source (script breakout)
  and run the __postError-defining script before the render script
- mobile createTask: compute next column order instead of hardcoding 0
  (new cards no longer collide)
- sync/engine makeConflictCopy: derive id from the HLC (collision-free)
  instead of Date.now(); keep the _conflict_<device>_<suffix> shape
- sync/engine insertTombstoneShell: let SQLite apply column defaults
  instead of copying raw PRAGMA dflt_value text
- sync/engine: reuse in-memory row for oplog payload in drainPending/
  backfill (drop redundant readRow)
- hlc decodeHlc: validate stamps, throw on malformed instead of NaN
- transport: sanitize deviceId in filenames; shape-validate parsed oplog
- rork provider: preserve base path in the request URL
- mobile sync.ts: timeout iCloud I/O so a stall can't wedge the scheduler
- sync controller: suppress queued rerun after stopAutoSync
- _layout: sync-startup failure no longer shows the DB error screen
- conflicts: confirm the destructive keep-copy; fix empty-vs-deleted label
- add hlc + transport tests
- note/[id]: reload on focus + inbound sync (useFocusEffect/useDataChanged),
  skipping while editing so in-progress edits aren't clobbered
- chat: restore image attachments into the agent conversation on history
  replay (multimodal context survived only in the UI before)
- NoteEditorToolbar: reset custom-AI input on dismiss
- shared/notes/format: distinguish bullet vs task prefixes, recognise
  checked tasks, and preserve checked state when re-toggling task
- shared/notes/text: stripMarkdown replaces punctuation with a space so
  adjacent words don't merge
- shared/format/date: UTC calendar-day math in formatDateCompact and
  getDueDateStatus (DST/timezone-safe)
- shared/notes/folder-tree: sort nested children, not just top level
- add format bullet/task tests
- card/[id]: memoize getCard(id) and listColumns by stable inputs so
  keystrokes don't re-query (and don't invalidate the tags memo)
- card/new: memoize listColumns(project)
- search: debounce search-as-you-type (200ms); scope switch runs
  immediately and cancels any pending query
- NoteEditorToolbar: memoize makeStyles(t) instead of rebuilding per render
- SyncSettings: text-[10px] -> rem class (respects font scale)
- card new/[id]: priority chip uses t.accentFg, not hardcoded #fff
- sheets: rgba backdrops -> withAlpha (Tag/DueDate/AiSettings/Wikilink)
- NoteEditorToolbar: accessibility labels/roles on icon-only buttons
- WikilinkPickerSheet: focus search via Modal onShow ref (not autoFocus)
- WebViewRenderer: block navigation via onShouldStartLoadWithRequest
- ai-config: allow keyless OpenAI-compatible endpoints when a custom
  base URL is set (LM Studio/Ollama)
- AiSettingsSheet: only write API key to keychain when it changed
- babel: drop explicit react-native-worklets/plugin (preset adds it)
- electron/db/queries: fix stale markDeleted() doc -> suppressNextChange()
- electron/db/schema: hoist SYNCABLE list so v25/v26 can't drift
- electron/main: static-import notes-files/file-watcher/queries/result-
  helpers (kept desktop-sync require for the shared-tsconfig boundary);
  this surfaced + fixed a real bug (deleteNoteFile called with 2 args);
  extract wireBackgroundSync helper
- pipeline: serialize full HAST node (attributes + nested) when escaping
  unknown tags, not just text children
- tsconfig: split shared (node-free) from shared-node so Node globals
  can't leak into platform-neutral shared modules
- docs: fix viability table header + label the diagram fence; refresh
  shared README Contents (format/notes/ui)
- mobile DB: add PRAGMA user_version migration runner (migrations.ts)
  so existing installs can pick up future schema changes; fresh installs
  stamp straight to the latest version
- chat-store: bound loadChatHistory to the most recent 200 messages and
  prune past 400; stop persisting image data URIs (kept live in-session)
  so the on-device DB doesn't bloat
- shared/sync/transport: add async writeOplogFileAsync/readPeerOplogsAsync
  (fs.promises); desktop syncDesktop is now async and awaits them so a
  slow/network-backed sync folder can't block the Electron main loop
- electron/main + sync-handlers: await the now-async syncDesktop
The batch-2 UTC-day change over-corrected: getDueDateStatus must treat
'today' as the user's LOCAL calendar day 'regardless of time' (a 23:55
local timestamp is still today) — UTC math broke that. Restore local-day
comparison but parse bare yyyy-MM-dd as a LOCAL date so the reviewer's
original timezone-shift concern is still fixed. formatDateCompact uses a
DST-safe local day-number helper. Fixes src/lib/utils.test.ts.

@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.

🧹 Nitpick comments (1)
tsconfig.shared-node.json (1)

15-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant include entries.

electron/db/schema.ts and electron/db/queries.ts are already matched by the electron/db/**/*.ts glob on the next line.

🧹 Proposed cleanup
   "include": [
     "shared/sync/transport.ts",
     "shared/sync/transport.test.ts",
-    "electron/db/schema.ts",
-    "electron/db/queries.ts",
     "electron/db/**/*.ts",
     "electron/sync/**/*.ts"
   ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tsconfig.shared-node.json` around lines 15 - 22, The include list in
tsconfig.shared-node.json has redundant explicit entries because
electron/db/schema.ts and electron/db/queries.ts are already covered by the
electron/db/**/*.ts glob. Update the shared-node tsconfig include array to
remove those duplicate paths and keep the broader glob plus the other unique
entries, so the config stays minimal and maintainable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tsconfig.shared-node.json`:
- Around line 15-22: The include list in tsconfig.shared-node.json has redundant
explicit entries because electron/db/schema.ts and electron/db/queries.ts are
already covered by the electron/db/**/*.ts glob. Update the shared-node tsconfig
include array to remove those duplicate paths and keep the broader glob plus the
other unique entries, so the config stays minimal and maintainable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e5e72eb3-9f20-4f03-8e3b-fceae531b645

📥 Commits

Reviewing files that changed from the base of the PR and between ca7692c and f27bf2f.

⛔ Files ignored due to path filters (1)
  • mobile/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (55)
  • .github/workflows/release-mobile.yml
  • docs/plans/mobile-app-viability.md
  • electron/db/queries.ts
  • electron/db/schema.ts
  • electron/main.ts
  • electron/sync/desktop-sync.ts
  • mobile/README.md
  • mobile/app.json
  • mobile/app/(tabs)/chat/index.tsx
  • mobile/app/(tabs)/search/index.tsx
  • mobile/app/_layout.tsx
  • mobile/app/card/[id].tsx
  • mobile/app/card/new.tsx
  • mobile/app/conflicts.tsx
  • mobile/app/note/[id].tsx
  • mobile/babel.config.js
  • mobile/eas.json
  • mobile/metro.config.js
  • mobile/package.json
  • mobile/src/chat/ai-config.ts
  • mobile/src/chat/providers/rork.ts
  • mobile/src/components/AiSettingsSheet.tsx
  • mobile/src/components/DueDatePickerSheet.tsx
  • mobile/src/components/MathView.tsx
  • mobile/src/components/MermaidView.tsx
  • mobile/src/components/NoteEditorToolbar.tsx
  • mobile/src/components/TagPickerSheet.tsx
  • mobile/src/components/UpdateBanner.tsx
  • mobile/src/components/WebViewRenderer.tsx
  • mobile/src/components/WikilinkPickerSheet.tsx
  • mobile/src/db/chat-store.ts
  • mobile/src/db/index.ts
  • mobile/src/db/migrations.ts
  • mobile/src/db/queries.ts
  • mobile/src/sync/controller.ts
  • mobile/src/sync/sync.ts
  • mobile/src/updates/useAppUpdates.ts
  • package.json
  • scripts/publishupdate.sh
  • scripts/releasemobile.sh
  • shared/README.md
  • shared/format/date.ts
  • shared/notes/folder-tree.ts
  • shared/notes/format.ts
  • shared/notes/text.ts
  • shared/shared.test.ts
  • shared/sync/engine.ts
  • shared/sync/hlc.test.ts
  • shared/sync/hlc.ts
  • shared/sync/transport.test.ts
  • shared/sync/transport.ts
  • src/components/settings/SyncSettings.tsx
  • src/lib/markdown/pipeline.tsx
  • tsconfig.shared-node.json
  • tsconfig.shared.json
💤 Files with no reviewable changes (1)
  • mobile/metro.config.js
✅ Files skipped from review due to trivial changes (3)
  • shared/sync/hlc.test.ts
  • scripts/publishupdate.sh
  • electron/db/queries.ts
🚧 Files skipped from review as they are similar to previous changes (36)
  • mobile/babel.config.js
  • mobile/src/components/DueDatePickerSheet.tsx
  • electron/db/schema.ts
  • shared/notes/text.ts
  • mobile/src/components/WikilinkPickerSheet.tsx
  • shared/notes/folder-tree.ts
  • mobile/src/db/index.ts
  • mobile/src/components/TagPickerSheet.tsx
  • mobile/app.json
  • mobile/package.json
  • src/components/settings/SyncSettings.tsx
  • mobile/app/conflicts.tsx
  • package.json
  • mobile/src/components/MathView.tsx
  • electron/main.ts
  • mobile/src/sync/sync.ts
  • mobile/app/card/new.tsx
  • mobile/app/(tabs)/search/index.tsx
  • mobile/src/chat/providers/rork.ts
  • src/lib/markdown/pipeline.tsx
  • mobile/app/card/[id].tsx
  • shared/sync/hlc.ts
  • electron/sync/desktop-sync.ts
  • mobile/app/_layout.tsx
  • shared/notes/format.ts
  • docs/plans/mobile-app-viability.md
  • mobile/app/note/[id].tsx
  • mobile/src/components/MermaidView.tsx
  • mobile/app/(tabs)/chat/index.tsx
  • mobile/src/components/NoteEditorToolbar.tsx
  • mobile/src/components/WebViewRenderer.tsx
  • shared/shared.test.ts
  • mobile/src/sync/controller.ts
  • shared/sync/engine.ts
  • mobile/src/components/AiSettingsSheet.tsx
  • mobile/src/db/queries.ts

electron/db/schema.ts and electron/db/queries.ts are already matched by
the electron/db/**/*.ts glob — remove the duplicate explicit paths.
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