Skip to content

Reagan/eng 4716 UI for desktop app#438

Merged
Cheggin merged 35 commits into
mainfrom
reagan/eng-4716-ui-for-desktop-app
May 25, 2026
Merged

Reagan/eng 4716 UI for desktop app#438
Cheggin merged 35 commits into
mainfrom
reagan/eng-4716-ui-for-desktop-app

Conversation

@Cheggin
Copy link
Copy Markdown
Collaborator

@Cheggin Cheggin commented May 25, 2026

Summary by cubic

Delivers the new desktop chat UI primitives (sandboxed HTML blocks, options/ask pickers, unified attachments) and structured renderer logging to improve UX, safety, and diagnostics. Also stabilizes chat edge cases and reduces duplicate outputs and console noise as part of ENG-4716.

  • New Features

    • Sandboxed HTML artifacts: render agent html fenced blocks in an iframe; theme-aware; adds neobrutalist-html skill and injects guidance into claude-code, codex, and browsercode.
    • Human-in-the-loop UI: streamed options picker and ask questionnaire with resume-on-submit.
    • Chat-v2 data model: typed parts transform; shared Attachments component; file_outputs collapse into grids; user-attached images shown inline via sessions:get-attachments-by-turn.
    • Structured renderer logging: makeLogger in the renderer + renderer:log IPC to write to on-disk renderer.log; migrated hub entry and EnginePicker.
    • Hub polish: compact Navbar, sidebar quick actions (New chat, Search), hide browser preview until a real URL.
  • Bug Fixes

    • Outputs watcher: emits one file_output per completed write and flushes pending events on fast engine exit.
    • ResizeObserver handling: rAF-batched sizing plus a shared noise filter to drop benign warnings across windows.
    • Picker/questionnaire state: fixed submission caching to persist complete answers across remounts; Ask free-text now restores by question identity to avoid mismatched text after reorders.
    • Transcript stability: chat-v2 transform now flushes streaming parts before non-text events to avoid stale UI.
    • Robustness: guarded renderer IPC and attachment rendering when APIs/DOM are unavailable; aligned BrowserPreview tests with live-URL gating.

Written for commit 3f12d58. Summary will update on new commits. Review in cubic

Cheggin added 30 commits May 18, 2026 17:37
Pure data layer for an AI SDK Elements-shaped message model: typed
parts (text, reasoning, tool, file, notify) and a side-effect-free
fromHlEvents adapter that converts the legacy HlEvent stream into
UIMessageV2 records. No renderer changes yet; this exists so we can
drop in individual elements components (Attachments, InlineCitation,
etc.) surgically without rewriting the transcript.

The 'thinking' HlEvent is mapped to a text part because in this
codebase it carries the assistant's streaming reply text (claude-code
content_block_delta.text_delta, codex agent_message), not chain-of-
thought. Real reasoning blocks would need a new HlEvent type.

Includes 19-case unit suite covering streaming, multi-turn flush,
tool timing pairing, error/done terminals, and done.summary fallback
for tool-only iterations.
Drops in an AI SDK Elements-shaped Attachments composable (Attachments
/ Attachment / AttachmentPreview / AttachmentInfo) and an
AttachmentList convenience helper, wired into ChatTurn so consecutive
non-hoisted file_output entries collapse into a single grid instead of
one FileCard per row. Single file_outputs (image or otherwise) also
flow through the same component so layout is consistent.

The trailing hoisted-image magazine layout is untouched — that's
intentional UX. Clicks on attachment chips still reveal the file in
the system file manager via the existing electronAPI bridge.
The renderer has ~134 ad-hoc console.* calls scattered across modules
with no on-disk trail — when something breaks in production, the only
record lives in DevTools. This adds a small structured logger:

  makeLogger('EnginePicker') → { debug, info, warn, error }

Console output keeps the existing [Module] action.subaction convention
so visual grep still works. Warn and error are forwarded over a new
renderer:log IPC channel to the existing rendererLogger ChannelLogger,
which writes JSONL to <userData>/logs/renderer.log alongside main and
browser logs. Info forwards only when localStorage.rendererLog is set
to 'verbose'; debug never forwards.

Includes payload sanitization on the main side (level whitelist,
namespace/message length caps, extras key cap, oversized string
truncation) so a bad renderer payload can't pollute the on-disk log.
26 unit tests cover both the IPC validator and the renderer-side
logger (forwarding rules, Error normalization, mode toggling).

The existing console.* call sites continue to work unchanged — this
is opt-in per module. See logger.README.md for the migration recipe.
Two highest-value call sites converted from console.* to makeLogger:

  - hub/index.tsx: window.error and unhandledrejection handlers. These
    capture renderer crashes — exactly the case where DevTools-only
    logging is least useful. They now hit the on-disk renderer.log.

  - hub/EnginePicker.tsx: 19 call sites across status refresh, install,
    login, and provider settings flows. Engine bring-up is the most
    common place users hit failures; persisting warn/error here gives
    a real trail to read after the fact.

About 110 other console.* call sites are left as-is. They keep working;
migrate opportunistically when touching the surrounding code.
User attachments (pasted/dropped images sent with a prompt) were
persisted into session_attachments by turn_index, but no IPC handler
exposed them to the renderer — so the chat transcript showed only the
prompt text and the image silently vanished from view even though the
agent received and processed it.

End-to-end wiring:
  - SessionManager.getAttachmentsByTurnIndex passthrough (manager owns
    session-scoped reads; the IPC handler doesn't touch the DB
    directly).
  - sessions:get-attachments-by-turn IPC handler — validates
    sessionId/turnIndex, encodes bytes as data: URLs with the recorded
    MIME so the renderer can <img src=...> without raw blob plumbing.
  - electronAPI.sessions.getAttachmentsByTurn preload bridge.
  - attachmentTurnIndex propagated through OutputEntry so the renderer
    knows which turn's blobs to fetch.
  - sessionId threaded through ChatTranscript → ChatTurn → UserBubble.
  - UserBubble lazily fetches on mount and renders via the existing
    AttachmentList composable above the bubble text.

Data URLs are sent lazily on demand, so the session payload stays
small. Image bytes never travel as raw buffers across IPC.
User-attached images now render as compact square cards (160-180px)
with the image filling the entire tile, instead of small thumb + name
chips. Filename and size fade in as a bottom overlay on hover/focus
so the visual is image-first like AI SDK Elements' attachments grid.

Gallery is a new variant on the existing Attachments composable; the
chip-style 'grid' variant is unchanged for agent file_outputs where
the name + meta are the primary affordance. UserBubble uses gallery.
180px was reading as a hero image above a short user prompt. 120px
keeps the gallery feeling like attachment chips, leaves the bubble
as the focal element, and still surfaces enough preview to be
recognizable. Hover overlay unchanged.
object-fit: cover at width/height: 100% was upscaling tiny images
(blur) and center-cropping wide screenshots (showed only the middle
strip). Switching to width/height: auto with max-width/height: 100%
and object-fit: contain means images render at their natural size up
to the 120px frame, then shrink-to-fit with aspect preserved.

Trade-off: a wide screenshot now gets dark letterbox bars top/bottom
inside the tile instead of a cropped strip. The hover overlay still
shows filename + size for context. Worth the safety — no pixelated
upscales, no surprise cropping.
Letterboxed thumbnails read as 'safe' but break the polished image-
forward grid the gallery variant is meant to look like. Going back
to cover — small images upscale and wide images get center-cropped,
which is the accepted trade-off for tiles that feel like attachment
cards rather than thumbnails.
fs.watch fires multiple change events while a file is being written.
The previous (filename, size) dedup let every intermediate size through,
so a single screenshot save produced 2-3 file_output events and the chat
rendered the same image multiple times. Debounce per filename (200ms)
and only emit after the size stops growing.
Previously the preview card rendered as soon as the session attached a
browser, showing a default-icon placeholder before any navigation. Wait
for a real http(s) URL before rendering the wrap at all.
Two coupled pieces for surfacing agent-emitted HTML artifacts (plans,
explanations, comparisons) as interactive cards instead of as inline
fenced-code markdown.

htmlBlocks.ts — streaming extractor
  Pure stateful parser: HtmlBlockExtractor.feed(chunk) → events. Hides
  three engines' chunking variance (claude-code's per-token deltas,
  codex's paragraph chunks, browsercode/opencode's one-liner parts)
  behind a single (text, html_block, text, ...) output. Uses strict
  fence regex during streaming (require \n terminator) and lax during
  the final end() flush (accept end-of-input) so fences arriving at
  the very last token still resolve. Closer uses a lookahead so the
  trailing \n stays in the buffer for the following text — fixes
  paragraph-break preservation.

HtmlBlock.tsx — sandboxed renderer
  iframe srcdoc + sandbox="allow-same-origin" (no allow-scripts).
  Static HTML+CSS only — JS in artifacts is intentionally blocked.
  Parent measures contentDocument.scrollHeight on load + via a
  ResizeObserver on documentElement/body so layout shifts from font/
  image loads reflect into the iframe height. Capped at 720px tall;
  overflowing content auto-collapses to 360px with an Expand toggle.
  Streaming-incomplete blocks render with a 'streaming…' badge so the
  partial render reads as intentional. <base target="_blank"> means
  any anchor opens in the user's default browser, not inside the
  sandboxed frame.

Hard benchmark: 36 unit tests covering single block, mixed prose,
back-to-back blocks, fence split at every chunk boundary (1-char
chunking invariance), never-closed streams, adversarial lookalike
content (```js, bare ```, mid-line fences), per-engine fixtures
(claude-code many-tiny-deltas, codex paragraph chunks, browsercode
one-liner), and round-trip byte preservation.

Not wired into ChatTurn yet — that's a separate change once we decide
the prompt-engineering side (system-prompt nudge for engines to emit
```html blocks for plans/explanations).
End-to-end: agent emits ```html fenced blocks → engine adapter system
prompt teaches every supported agent (claude-code, codex, opencode/
browsercode) when to use them → ChatTurn's StreamingProse runs the
extractor and renders each block as a sandboxed <HtmlBlock> instead
of inline markdown.

Provider-neutral guidance lives in skillIndexPrompt.ts as a new
constant HTML_BLOCK_GUIDANCE_LINES spread into every adapter's
wrapPrompt alongside the existing skill discovery lines. Adapter-side
changes are import-and-spread only — no per-engine branching.

Renderer:
  - StreamingProse short-circuits when no html block is present so the
    existing typewriter + stable-markdown flow is untouched for plain
    prose. When blocks ARE present, render text and html_block events
    in document order; typewriter is suppressed (it doesn't compose
    with iframe layout). Streaming-incomplete blocks surface their
    complete=false state down to HtmlBlock for the streaming badge.

Test spec for the floated-image case was adjusted to also accept the
existing .chat-step__image render path (first image becomes the
float anchor; only subsequent files flow through the Attachments
grid).
House style for agent-emitted ```html blocks: 3px solid #000 borders,
hard offset shadows (no blur), square corners, flat fills, two-accent
palette against a cream/off-white card lifted off the renderer's dark
surface. Documents the visual language, mandatory rules, system-font
stack (no external resources, sandbox blocks them), and component
recipes (card, button, status pill, step list, comparison, table)
plus a copy-paste reference artifact.

Lives in stock/interaction-skills so it ships with every harness and
shows up in 'agent-skill search'. The HTML_BLOCK_GUIDANCE_LINES nudge
in skillIndexPrompt.ts already tells agents when to use HTML blocks;
this skill answers the 'okay but what should they look like' question.
Theme propagation:
  - htmlBlockGuidanceLines(theme) replaces the static constant. Each
    engine adapter calls resolveThemeMode() at wrapPrompt time and
    passes the resolved 'light' | 'dark' into the function so the
    agent sees a 'UI THEME: light' / 'UI THEME: dark' line.
  - HtmlBlock reads useThemeMode() and threads the resolved value into
    wrap(), which swaps a small token set (fg, link, code bg, rules)
    per theme. The agent's own styles still win — these tokens are
    only the fallback for naked HTML.

Skill rewrite: 'neobrutalist-html.md' is now pure styling. Removed:
  - 'When to use' / 'When not to use' guidance (that's the agent's
    call per task)
  - Component recipes (card, button, status pill, step list,
    comparison, table)
  - The reference plan artifact

What stays: mandatory visual rules (3px solid #000 borders, hard
offset shadows, square corners, flat fills, system-font stack),
banned items, two palettes (light vs dark) with role-mapped hexes,
two-accent rule, typography sizes. The agent picks structure and
elements freely; we only constrain the look.
htmlBlockGuidanceLines(theme) now emits two lines instead of one:

  UI THEME: dark. When you emit a ```html block, use the active
  palette below. The full per-theme reference lives in the
  'neobrutalist-html' interaction skill.
  Active palette — card bg #1c1c20, border #f4ecd8, shadow #f4ecd8,
  foreground #f4ecd8. Accents: red #ff5252, blue #4ea3ff, green
  #3ddc84, gold #ffd400. Pick one bold accent + one secondary per
  artifact.

The agent can paste those hexes verbatim without re-reading the skill
file. The skill stays the source-of-truth reference for both palettes
together; this just removes the lookup step at use time. ACTIVE_PALETTE
in skillIndexPrompt.ts is hand-kept in sync with the skill — change
them together.
Restructure the hub sidebar to surface primary CTAs at the top:

- New top section with two rows: 'New chat' (returns to dashboard) and
  'Search' (opens the native pill).
- Existing agent list is now an explicit 'Agents' group below, with the
  + button moved into the group header.

Renderer-only; no IPC, no behavior changes for sessions.
Replace the ad-hoc filename pill row in the dashboard composer with the
existing AI SDK Elements-shaped <AttachmentList> from chat-v2. Image
attachments now render real thumbnails (via object URLs); non-image
files keep the file-icon-with-extension preview. Same component now
drives both dashboard composer and chat transcript, so any future
attachment polish lives in one place.

- AttachmentList/Attachment learn an optional onRemove + close-button
  overlay (revealed on hover). Chat usage is unaffected.
- Object URLs are revoked on remove/unmount.
- Lift the inline hub-toolbar header into a small <Navbar /> component;
  rename .hub-toolbar* CSS to .hub-navbar* to match.
- Drop the 'Browser Use' brand button and the top-right 'New agent' CTA
  (the sidebar's 'New chat' + per-row actions cover the same ground).
- Reduce navbar height 70px -> 46px and remove the 36px top padding;
  the chat/dashboard now starts much closer to the top edge.
- macOS only: pin the MemoryIndicator beside the traffic-light cluster
  at the same height/baseline (uses [data-platform='mac'] set in
  index.tsx via UA sniff). On Windows/Linux the indicator flows inline
  in the navbar with no chip chrome.
- MemoryIndicator and Settings buttons gain a subtle bordered hover
  treatment (border:1px transparent -> --color-border-subtle on hover)
  so they read as the same chip family without layout shift.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

17 issues found across 60 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/renderer/hub/chat-v2/Attachments.tsx">

<violation number="1" location="app/src/renderer/hub/chat-v2/Attachments.tsx:54">
P1: This component can render a nested `<button>` (remove button inside clickable attachment button) when both `onClick` and `onRemove` are provided.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/AskForm.tsx">

<violation number="1" location="app/src/renderer/hub/chat-v2/AskForm.tsx:133">
P2: `canSubmit` is true for an empty question list, which enables submitting an empty ask form.</violation>

<violation number="2" location="app/src/renderer/hub/chat-v2/AskForm.tsx:175">
P2: Cached submissions do not persist "Other" free-text values, causing submitted answers to re-render with missing content after remount.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/optionListStore.ts">

<violation number="1" location="app/src/renderer/hub/chat-v2/optionListStore.ts:16">
P2: Cache key construction is delimiter-ambiguous and can collide across different option/question sets, causing incorrect submission state restoration.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/fromHlEvents.ts">

<violation number="1" location="app/src/renderer/hub/chat-v2/fromHlEvents.ts:20">
P2: Module-level `_idCounter` makes `fromHlEvents` impure: repeated calls with the same events produce different IDs. If this is called on re-render, every message gets a new key, causing React to unmount/remount all children. Consider deriving deterministic IDs from the event index or accepting a seed/offset parameter.</violation>

<violation number="2" location="app/src/renderer/hub/chat-v2/fromHlEvents.ts:232">
P2: `file_output` doesn't close preceding streaming text/reasoning parts. After a `file_output` is appended, `closeStreamingText` (called later by `done`) iterates from the end, encounters the non-text file part, and returns without closing the earlier streaming text—leaving it in `state: 'streaming'` on a `status: 'done'` message.</violation>

<violation number="3" location="app/src/renderer/hub/chat-v2/fromHlEvents.ts:246">
P2: `notify` has the same issue as `file_output`—it doesn't close preceding streaming text/reasoning, leaving them stuck in `'streaming'` state if a `done` event follows.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/htmlBlocks.ts">

<violation number="1" location="app/src/renderer/hub/chat-v2/htmlBlocks.ts:481">
P2: Partial-parse path doesn't clamp `min` to 0 like `validateSection` does. A negative `"min"` value in the streaming JSON would pass through unclamped, producing an `OptionListSection` that violates the invariant the full-parse path enforces.</violation>
</file>

<file name="app/src/main/hl/engines/runEngine.ts">

<violation number="1" location="app/src/main/hl/engines/runEngine.ts:453">
P2: Pending debounced output events are dropped on process exit because timers are canceled instead of flushed.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/HtmlBlock.tsx">

<violation number="1" location="app/src/renderer/hub/chat-v2/HtmlBlock.tsx:75">
P2: Stale closure in ResizeObserver causes expand/collapse to malfunction. The ResizeObserver captures the initial `measureAndSet` where `naturalHeight` is always `FALLBACK_HEIGHT_PX` in the closure. On subsequent ticks, the auto-collapse condition is always met for tall content, re-collapsing the view even after the user clicks "Expand". Use a ref to track whether auto-collapse has already fired instead of relying on the stale state value.</violation>
</file>

<file name="app/src/main/hl/stock/interaction-skills/options-block.md">

<violation number="1" location="app/src/main/hl/stock/interaction-skills/options-block.md:62">
P2: "AND" should be "OR" — the intent is that options missing any of the three required fields (id, image, title) are dropped, not only options missing all three.</violation>

<violation number="2" location="app/src/main/hl/stock/interaction-skills/options-block.md:253">
P2: Broken cross-reference — "the JS snippet above" doesn't exist. The "Getting the data from the page" section only has prose principles, no code block.</violation>
</file>

<file name="app/src/main/index.ts">

<violation number="1" location="app/src/main/index.ts:1735">
P2: This handler eagerly base64-encodes and IPC-transfers bytes for every attachment, including non-images that the UI does not preview. For large PDFs/text files this can create very large synchronous payloads and cause noticeable main/renderer slowdown.</violation>
</file>

<file name="app/src/renderer/hub/chat-v2/OptionList.tsx">

<violation number="1" location="app/src/renderer/hub/chat-v2/OptionList.tsx:197">
P2: When submit is blocked only by missing "Other" text, the button label can show `section 0` due to an unhandled `findIndex` result of `-1`.</violation>

<violation number="2" location="app/src/renderer/hub/chat-v2/OptionList.tsx:278">
P1: Single-select Enter submits with stale state because submit is called from a deferred callback created before the selection state update is applied.</violation>
</file>

<file name="app/src/renderer/hub/chat/ChatTurn.tsx">

<violation number="1" location="app/src/renderer/hub/chat/ChatTurn.tsx:76">
P1: Guard the optional IPC call result before chaining promises; otherwise this can throw when the Electron bridge method is unavailable.</violation>

<violation number="2" location="app/src/renderer/hub/chat/ChatTurn.tsx:805">
P2: Use optional chaining on `.catch` (or guard the returned promise) to avoid click-time crashes when `revealOutput` is unavailable.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread app/src/renderer/hub/chat-v2/Attachments.tsx Outdated
Comment thread app/src/renderer/hub/chat-v2/OptionList.tsx Outdated
Comment thread app/src/renderer/hub/chat/ChatTurn.tsx Outdated
Comment thread app/src/renderer/hub/chat-v2/AskForm.tsx Outdated
Comment thread app/src/renderer/hub/chat-v2/AskForm.tsx
Comment thread app/src/main/hl/stock/interaction-skills/options-block.md Outdated
Comment thread app/src/main/hl/stock/interaction-skills/options-block.md Outdated
Comment thread app/src/main/index.ts Outdated
Comment thread app/src/renderer/hub/chat-v2/OptionList.tsx
Comment thread app/src/renderer/hub/chat/ChatTurn.tsx Outdated
Cheggin and others added 2 commits May 25, 2026 10:44
The BrowserPreview component now intentionally renders nothing until a session has a previewable http(s) lastUrl. The stale test still expected the old pre-navigation placeholder, so update the coverage to guard the current three-state contract: no URL renders nothing, URL without frame renders the placeholder, and URL with frame renders the image.

Constraint: BrowserPreview intentionally gates rendering on previewable lastUrl.

Rejected: Reverting the component to show the pre-navigation placeholder | that would reintroduce visual noise before navigation.

Confidence: high

Scope-risk: narrow

Tested: yarn test tests/unit/hub/BrowserPreview.spec.tsx

Tested: yarn eslint tests/unit/hub/BrowserPreview.spec.tsx

Not-tested: full yarn test blocked by unrelated better-sqlite3 Node ABI mismatch.

Co-authored-by: OmX <omx@oh-my-codex.dev>
The reported issues all traced to small state-boundary mismatches: cached answers lacked the complete payload, streaming transcript transforms left stale live parts behind later non-text events, and several renderer IPC/UI paths assumed capabilities or DOM structure that were not guaranteed.

This keeps the fixes local to the affected chat, option, attachment, output-watch, and documentation surfaces while adding regression coverage for each behavioral failure.

Constraint: The full unit suite is blocked locally by a better_sqlite3 native module ABI mismatch under Node v22.22.2.

Rejected: Change shared renderer architecture | each issue had a narrow local cause and regression surface.

Confidence: high

Scope-risk: moderate

Directive: Preserve deterministic event IDs and complete option submission records when extending chat-v2 transforms or stores.

Tested: yarn test tests/unit/chat-v2/Attachments.spec.tsx tests/unit/chat-v2/AskForm.spec.tsx tests/unit/chat-v2/OptionList.spec.tsx tests/unit/chat-v2/HtmlBlock.spec.tsx tests/unit/chat-v2/fromHlEvents.test.ts tests/unit/chat-v2/optionBlocks.test.ts tests/unit/chat-v2/optionListStore.test.ts tests/unit/hub/ChatTurn.spec.tsx tests/unit/hl/runEngineHarnessWatcher.test.ts

Tested: yarn typecheck

Tested: yarn lint

Tested: git diff --check

Not-tested: Full yarn test completion because tests/unit/sessions/SessionDb.schemaIdentity.test.ts fails to load better_sqlite3.node compiled for NODE_MODULE_VERSION 145 while current Node expects 127.

Co-authored-by: OmX <omx@oh-my-codex.dev>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 22 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread app/src/renderer/hub/chat-v2/AskForm.tsx Outdated
Ask form selections were already restored by encoded question identity, but the accompanying Other text still used the question array index. A reordered form with the same cache key could therefore display the right Other selection with another question's text.

This stores AskForm free-text answers in the submission record by the same per-question cache key used for selection restoration, while leaving OptionList's index-based section cache unchanged.

Constraint: Ask questions do not expose stable ids, so the cache identity is derived from header plus question text.

Rejected: Reuse the old otherText array fallback | it preserves the exact reorder bug for records without keyed values.

Confidence: high

Scope-risk: narrow

Directive: Do not restore AskForm free-text answers by array position unless ask questions gain explicit stable ids.

Tested: yarn test tests/unit/chat-v2/AskForm.spec.tsx

Tested: yarn typecheck

Tested: yarn lint

Tested: git diff --check

Co-authored-by: OmX <omx@oh-my-codex.dev>
@Cheggin Cheggin merged commit f143975 into main May 25, 2026
10 checks passed
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