Skip to content

Implement custom navigation, logging, and UI enhancements - #3

Merged
kevinliddel merged 8 commits into
mainfrom
feat/design
Jun 16, 2026
Merged

kevinliddel merged 8 commits into
mainfrom
feat/design

Conversation

@kevinliddel

@kevinliddel kevinliddel commented Jun 16, 2026 •

Copy link
Copy Markdown
Owner

This pull request introduces a project-wide structured logging system, improves diagnostics and error handling for inference and image generation, and adds a new feature for suggesting chat topics. The logging system now routes logs through both the OS and an in-app log viewer, making it easier to debug on-device issues. Additional safeguards and clearer error messages have been added to image generation, and the chat session now logs key events and can suggest topics for the home screen. Several minor usability and reliability improvements are included as well.

Logging and Diagnostics Improvements

  • Introduced a structured logging system (slog) that routes logs to both os.Logger and a thread-safe in-app LogStore, enabling export and in-app viewing of logs for easier debugging of on-device issues. (SynapLink/Core/Utils/SynapLog.swift, SynapLink/Core/Utils/LogStore.swift) [1] [2]
  • Added logging of key events in ChatSession and ImageGenerator, such as model loading, engine state transitions, errors, and media handling, to improve traceability and diagnostics. (SynapLink/Data/DataSources/Inference/ChatSession.swift, SynapLink/Data/DataSources/Inference/ImageGenerator.swift) [1] [2] [3] [4] [5] [6] [7] [8]
  • On app launch, log device memory and selected model configuration for better context in diagnostics. (SynapLink/App/SynapLinkApp.swift)

Image Generation Reliability and Error Handling

  • Added a memory availability check before starting image generation; if insufficient memory is available, the operation is refused with a clear and actionable error message. (SynapLink/Data/DataSources/Inference/ImageGenerator.swift, SynapLink/Data/DataSources/Inference/ImageGenError.swift) [1] [2]
  • Improved logging and error reporting for image generation failures, including SD model load and image encoding errors. (SynapLink/Data/DataSources/Inference/ImageGenerator.swift) [1] [2]

New Features

  • Added a suggestTopics method to ChatSession to generate and parse diverse, short topics for surfacing on the home screen, enhancing user experience with dynamic suggestions. (SynapLink/Data/DataSources/Inference/ChatSession.swift)

Audio and Model Compatibility

  • Changed AVAudioSession mode from .measurement to .default to ensure proper microphone gain for voice recordings, fixing an issue with inaudible recordings and empty transcripts on iPhone. (SynapLink/Data/DataSources/Audio/VoiceRecorder.swift)
  • Added a runtime check for Metal GPU capabilities to ensure whisper transcription uses the correct (CPU or GPU) backend, avoiding miscomputations on unsupported devices. (SynapLink/Data/DataSources/Inference/RuntimeProfile.swift) [1] [2]

Other Improvements

  • Updated the main app view to use RootTabView instead of ChatListView, likely as part of a new navigation structure. (SynapLink/App/SynapLinkApp.swift)
  • Adjusted C++ stable-diffusion logging to only forward warnings and errors, reducing log noise and preserving critical diagnostics. (SynapLink/Core/Engine/synap_sd.cpp)

Summary by CodeRabbit

  • New Features

    • Added topic suggestions on the Home screen to inspire new conversations
    • Introduced dark/light/system appearance settings
    • Added in-app log viewer for diagnostics
    • Enhanced message rendering with improved markdown support for quotes, lists, tables, and images
    • Redesigned navigation with a tab-based layout for easier app exploration
  • Bug Fixes

    • Improved audio recording quality to prevent quiet or empty transcriptions
    • Added memory checks before image generation to prevent out-of-memory errors
  • Improvements

    • Enhanced logging throughout the app for better diagnostics
    • Refined specialist model display labels and descriptions

@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kevinliddel, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 26 minutes and 36 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08404876-4f0b-406c-8b28-0221baeac23c

📥 Commits

Reviewing files that changed from the base of the PR and between 6225345 and b77d0fb.

📒 Files selected for processing (3)
  • .github/workflows/CI.yaml
  • SynapLink/Data/DataSources/Inference/ChatSession.swift
  • SynapLink/Data/DataSources/Inference/WhisperTranscriber.swift
📝 Walkthrough

Walkthrough

This PR introduces a tabbed RootTabView app shell driven by a new AppRouter singleton, replacing the single ChatListView entry point. A HomeView landing screen with AI-generated topic suggestions (TopicSuggester, ChatSession.suggestTopics) is added alongside a thread-safe structured logging layer (LogStore, slog), Metal-based GPU inference gating in RuntimeProfile, expanded markdown rendering with new StreamChunk types and a dedicated StreamChunkView, AppearanceSettings with a ThemeSwitcher, and a LogView diagnostics screen.

Changes

SynapLink App Shell, Home Screen, Logging, and Inference

Layer / File(s) Summary
Structured logging: LogStore, SynapLog, SD warn forwarding
SynapLink/Core/Utils/LogStore.swift, SynapLink/Core/Utils/SynapLog.swift, SynapLink/Core/Engine/synap_sd.cpp
LogLevel, LogEntry, and LogStore (NSLock ring buffer, 2 MB rolling file sink, snapshot, export) are introduced. slog() caches per-category os.Logger pairs, gates on isEnabled, and mirrors entries into LogStore. synap_sd.cpp forwards WARN/ERROR to stderr instead of silencing all levels.
Metal GPU gating, inference quality fixes, and slog instrumentation
SynapLink/Data/DataSources/Inference/RuntimeProfile.swift, SynapLink/Data/DataSources/Inference/WhisperTranscriber.swift, SynapLink/Data/DataSources/Inference/ImageGenerator.swift, SynapLink/Data/DataSources/Audio/VoiceRecorder.swift, SynapLink/Data/DataSources/Inference/ChatSession.swift, SynapLink/Data/DataSources/Inference/VisionDescriber.swift, SynapLink/Data/DataSources/ModelDownload/ModelDownloadManager.swift
RuntimeProfile gains metalHasSimdgroupMatrix, whisperUsesGPU, and imageGenUsesGPU (simulator-guarded). ImageGenerator adds an insufficientMemory error with a preflight free-memory check. WhisperTranscriber gains peak-amplitude normalization. VoiceRecorder switches to .default AVAudioSession category. ChatSession, VisionDescriber, and ModelDownloadManager receive slog calls throughout.
StreamChunk model expansion and StreamChunkView split
SynapLink/Presentation/Components/StreamChunk.swift, SynapLink/Presentation/Components/StreamChunkView.swift, SynapLink/Presentation/Components/Shimmer.swift
StreamChunk gains rule, quote, list, table, and updated image (with alt) cases plus ListItem, TableData, QuoteLine. StreamChunkParser is rewritten as an index-driven block scanner. StreamChunkView is extracted to its own file with QuoteView, ListBlockView, TableView, and MarkdownImageView. Shimmer ViewModifier is added.
AppRouter, RootTabView, SynapTabBar, and chat navigation wiring
SynapLink/Presentation/AppRouter.swift, SynapLink/Presentation/RootTabView.swift, SynapLink/Presentation/Components/SynapTabBar.swift, SynapLink/Presentation/Views/Chat/ChatScreen.swift, SynapLink/Presentation/Views/Chat/ChatListView.swift, SynapLink/Presentation/Views/Chat/ChatView.swift, SynapLink/App/SynapLinkApp.swift, SynapLink/Presentation/Views/Debug/SmokeTestView.swift, SynapLink/Presentation/Views/Chat/ImageGenSheet.swift
AppRouter singleton drives tab selection and presentedChat overlay. RootTabView composes SynapTabBar with an animated ChatScreen overlay. ChatScreen wraps ChatView in a NavigationStack with close/new-chat toolbar. ChatListView delegates navigation to router.openChat(). ChatView uses a viewport-height-driven dynamic spacer. App entry point switches to RootTabView.
AppearanceSettings and SettingsView ThemeSwitcher
SynapLink/Data/Repositories/AppearanceSettings.swift, SynapLink/Presentation/Views/Settings/SettingsView.swift
AppearanceMode enum and AppearanceSettings @Observable singleton with UserDefaults persistence are added. SettingsView gains ThemeSwitcher, appearance state, a modal ModelLibraryView sheet, and Logs/Performance Test navigation rows.
HomeView, TopicSuggester, SpecialistTile, and LogView
SynapLink/Presentation/Views/Home/HomeView.swift, SynapLink/Presentation/TopicSuggester.swift, SynapLink/Data/DataSources/Inference/ChatSession.swift (topics), SynapLink/Presentation/Views/ModelLibrary/ModelLibraryView.swift, SynapLink/Data/DataSources/ModelDownload/SpecialistCatalog.swift, SynapLink/Presentation/Views/Settings/LogView.swift, SynapLink/Presentation/Components/ChatInputBar.swift
HomeView composes greeting, action-card grid, and a Popular Topics section with shimmer skeleton rows. TopicSuggester manages session-cached topic state with loadIfNeeded/refresh. ChatSession.suggestTopics and parseTopics power LLM-backed suggestions. ModelLibraryView replaces SpecialistCard with an adaptive-grid SpecialistTile. LogView displays, shares, and clears LogStore entries. ChatInputBar button logic and corner radius are updated.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant HomeView
  participant TopicSuggester
  participant ChatSession
  participant AppRouter
  participant RootTabView
  participant ChatScreen

  User->>HomeView: onAppear
  HomeView->>TopicSuggester: loadIfNeeded()
  TopicSuggester->>ChatSession: suggestTopics(count:5)
  ChatSession-->>TopicSuggester: [String]
  TopicSuggester-->>HomeView: topics (skeleton → rows)

  User->>HomeView: tap topic row
  HomeView->>AppRouter: openNewChat()
  AppRouter->>ChatSession: startNewChat()
  AppRouter->>AppRouter: presentedChat = newChat
  AppRouter-->>RootTabView: presentedChat updated
  RootTabView->>ChatScreen: slide-up overlay
  HomeView->>ChatSession: send seeded topic message

  User->>ChatScreen: tap close
  ChatScreen->>AppRouter: closeChat()
  AppRouter->>AppRouter: presentedChat = nil
  RootTabView-->>HomeView: overlay dismissed
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 Hoppin' through tabs, a new home appears,
With topic suggestions to spark all your gears!
LogStore remembers each whisper and gleam,
Metal gates the GPU, smooth as a dream.
Tables and quotes in the chat bubble flow —
A shimmer, a shuffle, and off we all go! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: implementing custom navigation (RootTabView, AppRouter, SynapTabBar), logging infrastructure (LogStore, SynapLog), and UI enhancements (HomeView, theme switching, shimmer effects, improved StreamChunk rendering).
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/design

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 and usage tips.

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

🧹 Nitpick comments (3)
SynapLink/Presentation/Components/Shimmer.swift (1)

17-22: ⚡ Quick win

Respect Reduce Motion when applying shimmer.

Gate shimmer animation with accessibilityReduceMotion and fall back to a static placeholder when motion reduction is enabled.

Also applies to: 52-57

🤖 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 `@SynapLink/Presentation/Components/Shimmer.swift` around lines 17 - 22, The
shimmer animation in the body method does not respect the user's accessibility
reduce motion setting, which can cause discomfort for users who have enabled
this preference. Use the `@Environment` property wrapper to access the
accessibilityReduceMotion variable and conditionally apply either the shimmer
animation with the AnimatedMask modifier when reduce motion is disabled, or
return a static placeholder view (without the animation and onAppear modifier)
when reduce motion is enabled. Apply this same conditional logic at all
locations where shimmer animations are defined.
SynapLink/Presentation/Components/ChatInputBar.swift (1)

36-36: 💤 Low value

Remove unused canSubmit property.

The canSubmit computed property is defined but never used in the updated button logic (lines 80-88). The button disabled states now directly use hasDraft and canSend instead.

🧹 Proposed cleanup
-    /// A turn can be sent with text, an attachment, or both.
-    private var canSubmit: Bool { hasDraft || hasAttachment }
-
🤖 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 `@SynapLink/Presentation/Components/ChatInputBar.swift` at line 36, The
canSubmit computed property in ChatInputBar is no longer used since the button
logic now directly references hasDraft and canSend instead. Remove the entire
canSubmit property definition that computes hasDraft || hasAttachment to
eliminate dead code and reduce maintenance burden.
SynapLink/Data/Repositories/AppearanceSettings.swift (1)

44-59: 💤 Low value

Consider marking AppearanceSettings as @MainActor for explicit thread-safety guarantees.

While UserDefaults and @Observable provide their own synchronization, and SwiftUI views implicitly run on the main thread, adding @MainActor to the class would make the main-thread requirement explicit and prevent accidental background-thread access.

🧵 Suggested annotation
+@MainActor
 `@Observable`
 final class AppearanceSettings {
🤖 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 `@SynapLink/Data/Repositories/AppearanceSettings.swift` around lines 44 - 59,
The AppearanceSettings class is missing explicit thread-safety guarantees
required for classes that interact with UserDefaults and SwiftUI. Add the
`@MainActor` attribute to the AppearanceSettings class declaration (currently
marked with `@Observable`) to explicitly require main-thread access and prevent
accidental background-thread usage.
🤖 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.

Inline comments:
In `@SynapLink/Core/Utils/LogStore.swift`:
- Around line 60-64: The static stamp DateFormatter is not thread-safe and is
being accessed after the lock is released in the record() method, creating
potential race conditions with concurrent calls. Replace the DateFormatter-based
stamp variable with ISO8601DateFormatter (which is thread-safe per Apple
documentation), or alternatively move the stamp formatter access inside the lock
protection to ensure thread-safe access. If using ISO8601DateFormatter, update
the date formatting call site (around line 76 where the lock is released) to use
the new formatter's output method instead of the dateString(from:) call.

In `@SynapLink/Data/DataSources/Inference/ChatSession.swift`:
- Around line 284-286: The code logs raw user-derived text content
(resolved.promptText in the media turn logging block and transcript at another
location) to persistent diagnostics, creating a privacy and compliance risk.
Instead of logging the actual text content, replace these with metadata such as
character counts, token counts, or simple boolean flags to indicate that content
was present, while completely removing the raw text from the log statement.
- Around line 365-367: The guard statement in the suggestTopics method only
checks the isGenerating flag but does not check the isCreatingImage flag. Add a
check for !isCreatingImage to the guard condition on line 366 alongside the
existing !isGenerating check. This will prevent the method from calling
ensureEngineLoaded() while image generation is active, avoiding unnecessary
memory pressure during image creation operations.

In `@SynapLink/Presentation/Components/StreamChunk.swift`:
- Around line 234-244: The `imageOnly` function uses `firstIndex(of: ")")` to
find the closing parenthesis of the URL, but this incorrectly stops at the first
`)` character, breaking URLs that contain parentheses like
`https://host/a_(b).png`. Instead of finding the first closing parenthesis, find
the correct one by counting parenthesis nesting depth starting from the opening
`(` after `]` and finding where the depth returns to zero, or alternatively by
scanning backwards from the end of the line to find the last `)` character,
which should be the actual closing parenthesis of the markdown syntax. Update
the logic where `close` is assigned to implement one of these approaches.

In `@SynapLink/Presentation/Components/StreamChunkView.swift`:
- Around line 163-176: The AsyncImage in the body property automatically fetches
remote images from untrusted markdown URLs when the view appears, potentially
exposing client metadata to malicious hosts. Replace the automatic AsyncImage
loading with an explicit user-triggered mechanism (such as a "Tap to load image"
button) or implement a trusted-host allowlist that validates the URL domain
before calling AsyncImage. Only proceed with AsyncImage instantiation if the
host is verified as safe, otherwise show a placeholder or button prompting user
action instead of automatically fetching.

In `@SynapLink/Presentation/Views/Chat/ChatView.swift`:
- Around line 216-230: The onChange observer at line 220 only scrolls to the top
when the last message is a user message, leaving assistant messages unhandled.
Additionally, onAppear at line 226 may fire before chat data is loaded. To fix
this, modify the onChange closure to also scroll to the bottom when the last
message is from the assistant role (not just the user role), ensuring existing
chats that end with an assistant message properly anchor to the bottom. This
way, both user and assistant message changes will trigger appropriate scrolling
behavior.

In `@SynapLink/Presentation/Views/Settings/SettingsView.swift`:
- Around line 211-217: The onTapGesture closure in SettingsView currently
toggles only between .light and .dark appearance modes, making the .system mode
unreachable once changed. Update the toggle logic within the withAnimation block
to cycle through all three modes: .system, .light, and .dark. Replace the
current ternary expression that only handles two states with a conditional
statement (if-else or switch) that checks the current mode value and cycles to
the next mode in sequence, ensuring .system is reachable again after cycling
through the other modes.

---

Nitpick comments:
In `@SynapLink/Data/Repositories/AppearanceSettings.swift`:
- Around line 44-59: The AppearanceSettings class is missing explicit
thread-safety guarantees required for classes that interact with UserDefaults
and SwiftUI. Add the `@MainActor` attribute to the AppearanceSettings class
declaration (currently marked with `@Observable`) to explicitly require
main-thread access and prevent accidental background-thread usage.

In `@SynapLink/Presentation/Components/ChatInputBar.swift`:
- Line 36: The canSubmit computed property in ChatInputBar is no longer used
since the button logic now directly references hasDraft and canSend instead.
Remove the entire canSubmit property definition that computes hasDraft ||
hasAttachment to eliminate dead code and reduce maintenance burden.

In `@SynapLink/Presentation/Components/Shimmer.swift`:
- Around line 17-22: The shimmer animation in the body method does not respect
the user's accessibility reduce motion setting, which can cause discomfort for
users who have enabled this preference. Use the `@Environment` property wrapper to
access the accessibilityReduceMotion variable and conditionally apply either the
shimmer animation with the AnimatedMask modifier when reduce motion is disabled,
or return a static placeholder view (without the animation and onAppear
modifier) when reduce motion is enabled. Apply this same conditional logic at
all locations where shimmer animations are defined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f999f6f-13e7-4e57-ad9a-3cfeb9486983

📥 Commits

Reviewing files that changed from the base of the PR and between 467f2f0 and 6225345.

📒 Files selected for processing (30)
  • SynapLink/App/SynapLinkApp.swift
  • SynapLink/Core/Engine/synap_sd.cpp
  • SynapLink/Core/Utils/LogStore.swift
  • SynapLink/Core/Utils/SynapLog.swift
  • SynapLink/Data/DataSources/Audio/VoiceRecorder.swift
  • SynapLink/Data/DataSources/Inference/ChatSession.swift
  • SynapLink/Data/DataSources/Inference/ImageGenerator.swift
  • SynapLink/Data/DataSources/Inference/RuntimeProfile.swift
  • SynapLink/Data/DataSources/Inference/VisionDescriber.swift
  • SynapLink/Data/DataSources/Inference/WhisperTranscriber.swift
  • SynapLink/Data/DataSources/ModelDownload/ModelDownloadManager.swift
  • SynapLink/Data/DataSources/ModelDownload/SpecialistCatalog.swift
  • SynapLink/Data/Repositories/AppearanceSettings.swift
  • SynapLink/Presentation/AppRouter.swift
  • SynapLink/Presentation/Components/ChatInputBar.swift
  • SynapLink/Presentation/Components/Shimmer.swift
  • SynapLink/Presentation/Components/StreamChunk.swift
  • SynapLink/Presentation/Components/StreamChunkView.swift
  • SynapLink/Presentation/Components/SynapTabBar.swift
  • SynapLink/Presentation/RootTabView.swift
  • SynapLink/Presentation/TopicSuggester.swift
  • SynapLink/Presentation/Views/Chat/ChatListView.swift
  • SynapLink/Presentation/Views/Chat/ChatScreen.swift
  • SynapLink/Presentation/Views/Chat/ChatView.swift
  • SynapLink/Presentation/Views/Chat/ImageGenSheet.swift
  • SynapLink/Presentation/Views/Debug/SmokeTestView.swift
  • SynapLink/Presentation/Views/Home/HomeView.swift
  • SynapLink/Presentation/Views/ModelLibrary/ModelLibraryView.swift
  • SynapLink/Presentation/Views/Settings/LogView.swift
  • SynapLink/Presentation/Views/Settings/SettingsView.swift

Comment on lines +60 to +64
private static let stamp: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

DateFormatter is not thread-safe; concurrent record() calls can race.

DateFormatter instances are not thread-safe per Apple documentation. The static stamp formatter is accessed at line 76 after the lock is released, so concurrent calls to record() from different threads can corrupt the formatter's internal state.

Consider using ISO8601DateFormatter (which is thread-safe) or protecting access with a lock.

🔒 Suggested fix using ISO8601DateFormatter
-    private static let stamp: DateFormatter = {
-        let f = DateFormatter()
-        f.dateFormat = "HH:mm:ss.SSS"
-        return f
-    }()
+    private static let stamp: ISO8601DateFormatter = {
+        let f = ISO8601DateFormatter()
+        f.formatOptions = [.withTime, .withFractionalSeconds, .withColonSeparatorInTime]
+        return f
+    }()

Alternatively, use the lock to protect formatter access, or create a formatter per call (slower but safe).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static let stamp: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
private static let stamp: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withTime, .withFractionalSeconds, .withColonSeparatorInTime]
return f
}()
🤖 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 `@SynapLink/Core/Utils/LogStore.swift` around lines 60 - 64, The static stamp
DateFormatter is not thread-safe and is being accessed after the lock is
released in the record() method, creating potential race conditions with
concurrent calls. Replace the DateFormatter-based stamp variable with
ISO8601DateFormatter (which is thread-safe per Apple documentation), or
alternatively move the stamp formatter access inside the lock protection to
ensure thread-safe access. If using ISO8601DateFormatter, update the date
formatting call site (around line 76 where the lock is released) to use the new
formatter's output method instead of the dateString(from:) call.

Comment on lines +284 to +286
if !attachments.isEmpty {
slog("media turn → \(resolved.directMedia.count) direct, user text: \"\(resolved.promptText)\"", .info)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging raw user/transcript content to persistent diagnostics.

Line 285 and Line 342 log full user-derived text (resolved.promptText, transcript). With current slog behavior, that data is persisted/exportable, which is a privacy/compliance risk. Log metadata (counts/flags) instead of raw content.

🔧 Suggested change
-                if !attachments.isEmpty {
-                    slog("media turn → \(resolved.directMedia.count) direct, user text: \"\(resolved.promptText)\"", .info)
-                }
+                if !attachments.isEmpty {
+                    slog("media turn → \(resolved.directMedia.count) direct, promptChars=\(resolved.promptText.count)", .info)
+                }
...
-                    slog("voice transcript: \"\(transcript)\"", transcript.isEmpty ? .warning : .info)
+                    slog("voice transcript chars=\(transcript.count)", transcript.isEmpty ? .warning : .info)

Also applies to: 342-343

🤖 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 `@SynapLink/Data/DataSources/Inference/ChatSession.swift` around lines 284 -
286, The code logs raw user-derived text content (resolved.promptText in the
media turn logging block and transcript at another location) to persistent
diagnostics, creating a privacy and compliance risk. Instead of logging the
actual text content, replace these with metadata such as character counts, token
counts, or simple boolean flags to indicate that content was present, while
completely removing the raw text from the log statement.

Comment on lines +365 to +367
await ensureEngineLoaded()
guard case .ready = engineState, !isGenerating else { return [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block topic suggestion while image generation is active.

Line 366 only checks !isGenerating. If isCreatingImage is true, suggestTopics can still call ensureEngineLoaded(), potentially loading the chat engine during SD generation and spiking memory pressure.

🔧 Suggested change
-        guard case .ready = engineState, !isGenerating else { return [] }
+        guard case .ready = engineState, !isGenerating, !isCreatingImage else { return [] }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await ensureEngineLoaded()
guard case .ready = engineState, !isGenerating else { return [] }
await ensureEngineLoaded()
guard case .ready = engineState, !isGenerating, !isCreatingImage else { return [] }
🤖 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 `@SynapLink/Data/DataSources/Inference/ChatSession.swift` around lines 365 -
367, The guard statement in the suggestTopics method only checks the
isGenerating flag but does not check the isCreatingImage flag. Add a check for
!isCreatingImage to the guard condition on line 366 alongside the existing
!isGenerating check. This will prevent the method from calling
ensureEngineLoaded() while image generation is active, avoiding unnecessary
memory pressure during image creation operations.

Comment on lines +234 to +244
private static func imageOnly(_ line: String) -> (alt: String, url: String)? {
// ![alt](url "title") — url stops at whitespace or ).
guard line.hasPrefix("!["), let altEnd = line.firstIndex(of: "]"),
line.index(after: altEnd) < line.endIndex,
line[line.index(after: altEnd)] == "(" else { return nil }
let alt = String(line[line.index(line.startIndex, offsetBy: 2)..<altEnd])
let afterParen = line[line.index(altEnd, offsetBy: 2)...]
guard let close = afterParen.firstIndex(of: ")") else { return nil }
let inside = afterParen[..<close]
let url = inside.split(separator: " ").first.map(String.init) ?? String(inside)
return (alt, url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle image URLs that contain parentheses.

imageOnly stops at the first ), so valid markdown like ![img](https://host/a_(b).png) gets truncated and fails to render.

Proposed fix
 private static func imageOnly(_ line: String) -> (alt: String, url: String)? {
@@
-    let afterParen = line[line.index(altEnd, offsetBy: 2)...]
-    guard let close = afterParen.firstIndex(of: ")") else { return nil }
+    let afterParen = line[line.index(altEnd, offsetBy: 2)...]
+    var nesting = 0
+    var close: String.Index?
+    var idx = afterParen.startIndex
+    while idx < afterParen.endIndex {
+        let ch = afterParen[idx]
+        if ch == "(" {
+            nesting += 1
+        } else if ch == ")" {
+            if nesting == 0 {
+                close = idx
+                break
+            }
+            nesting -= 1
+        }
+        idx = afterParen.index(after: idx)
+    }
+    guard let close else { return nil }
     let inside = afterParen[..<close]
     let url = inside.split(separator: " ").first.map(String.init) ?? String(inside)
     return (alt, url)
 }
🤖 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 `@SynapLink/Presentation/Components/StreamChunk.swift` around lines 234 - 244,
The `imageOnly` function uses `firstIndex(of: ")")` to find the closing
parenthesis of the URL, but this incorrectly stops at the first `)` character,
breaking URLs that contain parentheses like `https://host/a_(b).png`. Instead of
finding the first closing parenthesis, find the correct one by counting
parenthesis nesting depth starting from the opening `(` after `]` and finding
where the depth returns to zero, or alternatively by scanning backwards from the
end of the line to find the last `)` character, which should be the actual
closing parenthesis of the markdown syntax. Update the logic where `close` is
assigned to implement one of these approaches.

Comment on lines +163 to +176
var body: some View {
if let parsed = URL(string: url), parsed.scheme == "http" || parsed.scheme == "https" {
AsyncImage(url: parsed) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
case .failure:
placeholder
default:
ProgressView().frame(maxWidth: .infinity).frame(height: 80)
}
}
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "StreamChunkView.swift" -type f

Repository: kevinliddel/SynapLink

Length of output: 123


🏁 Script executed:

wc -l ./SynapLink/Presentation/Components/StreamChunkView.swift

Repository: kevinliddel/SynapLink

Length of output: 127


🏁 Script executed:

cat -n ./SynapLink/Presentation/Components/StreamChunkView.swift

Repository: kevinliddel/SynapLink

Length of output: 7579


🏁 Script executed:

find . -name "*.swift" -type f | xargs grep -l "enum StreamChunk" | head -5

Repository: kevinliddel/SynapLink

Length of output: 119


🏁 Script executed:

cat -n ./SynapLink/Presentation/Components/StreamChunk.swift

Repository: kevinliddel/SynapLink

Length of output: 11436


🏁 Script executed:

rg "StreamChunkParser.parse" -A 3 -B 3

Repository: kevinliddel/SynapLink

Length of output: 1355


🏁 Script executed:

head -50 ./SynapLink/Presentation/Components/MarkdownText.swift

Repository: kevinliddel/SynapLink

Length of output: 1595


🏁 Script executed:

rg "MarkdownText(" -B 2 -A 2 | head -40

Repository: kevinliddel/SynapLink

Length of output: 142


🏁 Script executed:

rg "MarkdownText\(" -B 2 -A 2 | head -60

Repository: kevinliddel/SynapLink

Length of output: 924


🏁 Script executed:

rg "struct MessageBubble" -A 20

Repository: kevinliddel/SynapLink

Length of output: 1831


🏁 Script executed:

rg "MessageBubble\(" -B 1 -A 3 | head -50

Repository: kevinliddel/SynapLink

Length of output: 1762


🏁 Script executed:

rg "AsyncImage|URLSession|network|policy|image.*domain|allow.*list|trust" -i | head -40

Repository: kevinliddel/SynapLink

Length of output: 625


🌐 Web query:

AsyncImage SwiftUI auto-fetch behavior when URL is provided on init

💡 Result:

In SwiftUI, the AsyncImage view initiates the image fetching process automatically upon appearing in the view hierarchy [1][2]. When you provide a URL during initialization, the view does not start the network request until the view is rendered and appears on screen [1]. Key behavioral details: - Triggered on appear: The download is triggered when the view appears [1][2]. - Lifecycle management: If the view is removed from the view hierarchy (e.g., a row in a scrollable list is scrolled off-screen) before the download completes, the underlying URL request is automatically canceled [3][2]. - Mechanism: It utilizes the shared URLSession instance to perform the network request [4][5]. - Caching: By default, AsyncImage relies on the system's shared URLSession, which handles standard URL loading, including memory caching [3][2]. However, for advanced use cases requiring specific persistent on-disk caching, custom headers, or fine-grained control, developers often implement a custom image loader [3][2]. For scenarios where you need to react to the loading state (e.g., displaying a progress view while loading or handling errors), you should use the phase-based initializer (init(url:scale:transaction:content:)), which provides an AsyncImagePhase enum to track the current state [4][6][7].

Citations:


🏁 Script executed:

rg "URLSession|imageLoader|ImageCache|image.*download" -i | head -30

Repository: kevinliddel/SynapLink

Length of output: 232


Avoid automatic remote image fetches for untrusted markdown URLs.

AsyncImage initiates network requests when the view appears, without explicit user interaction. This sends client metadata (IP address, user-agent, request timing) to arbitrary hosts embedded in model-generated markdown, enabling tracking and reconnaissance attacks. Implement explicit user-triggered loading (e.g., "Tap to load image") or require a trusted-host allowlist before calling AsyncImage.

🤖 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 `@SynapLink/Presentation/Components/StreamChunkView.swift` around lines 163 -
176, The AsyncImage in the body property automatically fetches remote images
from untrusted markdown URLs when the view appears, potentially exposing client
metadata to malicious hosts. Replace the automatic AsyncImage loading with an
explicit user-triggered mechanism (such as a "Tap to load image" button) or
implement a trusted-host allowlist that validates the URL domain before calling
AsyncImage. Only proceed with AsyncImage instantiation if the host is verified
as safe, otherwise show a placeholder or button prompting user action instead of
automatically fetching.

Comment on lines +216 to +230
.onChange(of: session.messages.last?.id) {
guard let last = session.messages.last else { return }
// A new user message jumps to the top; a finished reply just
// settles where it is.
if last.role == .user {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo("bottom", anchor: .bottom)
proxy.scrollTo(last.id, anchor: .top)
}
}
}
.onAppear {
if let last = session.messages.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve initial bottom anchoring after chat data loads.

Line 226 can fire before the target chat messages are populated, and Line 220 only re-scrolls for .user. Existing chats that end with an assistant message can open at the top and never re-anchor.

Proposed fix
@@
     `@State` private var specialists = SpecialistManager.shared
     `@State` private var viewportHeight: CGFloat = 0
+    `@State` private var didInitialAnchor = false
@@
             .onChange(of: session.messages.last?.id) {
                 guard let last = session.messages.last else { return }
+                if !didInitialAnchor {
+                    didInitialAnchor = true
+                    proxy.scrollTo(last.id, anchor: .bottom)
+                    return
+                }
                 // A new user message jumps to the top; a finished reply just
                 // settles where it is.
                 if last.role == .user {
                     withAnimation(.easeOut(duration: 0.25)) {
                         proxy.scrollTo(last.id, anchor: .top)
@@
             .onAppear {
                 if let last = session.messages.last {
+                    didInitialAnchor = true
                     proxy.scrollTo(last.id, anchor: .bottom)
                 }
             }
🤖 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 `@SynapLink/Presentation/Views/Chat/ChatView.swift` around lines 216 - 230, The
onChange observer at line 220 only scrolls to the top when the last message is a
user message, leaving assistant messages unhandled. Additionally, onAppear at
line 226 may fire before chat data is loaded. To fix this, modify the onChange
closure to also scroll to the bottom when the last message is from the assistant
role (not just the user role), ensuring existing chats that end with an
assistant message properly anchor to the bottom. This way, both user and
assistant message changes will trigger appropriate scrolling behavior.

Comment on lines +211 to +217
.onTapGesture {
withAnimation(.snappy(duration: 0.22)) { mode = isDark ? .light : .dark }
}
.accessibilityElement()
.accessibilityLabel("Theme")
.accessibilityValue(isDark ? "Dark" : "Light")
.accessibilityAddTraits(.isButton)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Allow users to return to .system appearance mode.

Line 212 only toggles .light/.dark, so once changed, .system (which AppearanceSettings persists/supports) becomes unreachable from UI.

Proposed fix
@@
         .onTapGesture {
-            withAnimation(.snappy(duration: 0.22)) { mode = isDark ? .light : .dark }
+            withAnimation(.snappy(duration: 0.22)) {
+                switch mode {
+                case .system: mode = (systemScheme == .dark) ? .light : .dark
+                case .light: mode = .dark
+                case .dark: mode = .system
+                }
+            }
         }
@@
-        .accessibilityValue(isDark ? "Dark" : "Light")
+        .accessibilityValue(mode.label)
🤖 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 `@SynapLink/Presentation/Views/Settings/SettingsView.swift` around lines 211 -
217, The onTapGesture closure in SettingsView currently toggles only between
.light and .dark appearance modes, making the .system mode unreachable once
changed. Update the toggle logic within the withAnimation block to cycle through
all three modes: .system, .light, and .dark. Replace the current ternary
expression that only handles two states with a conditional statement (if-else or
switch) that checks the current mode value and cycles to the next mode in
sequence, ensuring .system is reachable again after cycling through the other
modes.

…dio resampler logic, and fixed language tagging in CI pipeline.
@kevinliddel
kevinliddel merged commit 55042a4 into main Jun 16, 2026
5 checks passed
@kevinliddel
kevinliddel deleted the feat/design branch June 16, 2026 13:56
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