Skip to content

Add theming system with 9 color themes - #4

Merged
frenchie4111 merged 3 commits into
mainfrom
themese
Apr 10, 2026
Merged

frenchie4111 merged 3 commits into
mainfrom
themese

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

Summary

  • Introduces a CSS-variable based theme system using Tailwind v4 @theme tokens. Major panels (sidebar, terminal tab bar, PR status, changed files, diff view, settings) now use semantic classes like bg-panel, text-muted, border-border, text-success instead of hardcoded neutral-*/green-*/etc.
  • Themes are stored in config.json, applied via document.documentElement.dataset.theme, and live-update across windows via IPC broadcast. The Electron window backgroundColor is seeded from a THEME_APP_BG map so first paint matches the theme.
  • New Appearance section in Settings with a theme picker (swatches + description). Ships 9 themes: Dark (default), Dracula, Nord, Gruvbox Dark, Tokyo Night, Catppuccin Mocha, One Dark, Solarized Dark, Solarized Light.
  • XTerminal reads --color-app / --color-fg-bright at init so the xterm background blends into the surrounding panel.

Test plan

  • npm run dev, open Settings → Appearance, cycle through each theme and confirm panels recolor immediately
  • Reload the window and confirm the selected theme persists
  • Quit + relaunch and confirm no flash of default dark before the theme paints (tests the backgroundColor seeding)
  • Open a Claude tab in each theme and confirm the terminal background matches the surrounding panel
  • Open a diff tab and confirm add/remove/hunk lines use theme colors
  • Open a worktree with a PR and confirm status dots, PR check icons, and changed-files status letters all recolor per theme

🤖 Generated with Claude Code

frenchie4111 and others added 3 commits April 10, 2026 11:30
Introduces a CSS-variable based theme system with semantic tokens
(panel, surface, border, fg, muted, success, warning, etc.) that the
major panels now use instead of hardcoded Tailwind neutral classes.
Themes are persisted in config.json and switched via a new Appearance
section in Settings.

Bundled themes: Dark (default), Dracula, Nord, Gruvbox Dark, Tokyo
Night, Catppuccin Mocha, One Dark, Solarized Dark, Solarized Light.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously --color-panel matched --color-app in every theme, so the
sidebar and right pane blended into the terminal. Nudging panel one
step toward panel-raised (or darker for the light theme) gives the
chrome a subtle lift without making it feel busy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@frenchie4111
frenchie4111 merged commit 32a7dfc into main Apr 10, 2026
tresat added a commit to tresat/harness that referenced this pull request Jul 24, 2026
- Extract normalizeAlias() + ALIAS_MAX_LEN in shared/state/aliases.ts;
  IPC handler, MCP control-server dep, and control-server response
  echo all use it so the trim/clamp logic can't drift (fixes ness-dev#2)
- useHotkeys skips when focused element (or ancestor) has
  data-hotkeys="ignore"; AliasEditor input opts in so Cmd+W / Cmd+K /
  etc. don't fire while renaming. Opt-in (not a general "skip inputs"
  rule) because xterm's hidden textarea would otherwise break every
  terminal-time hotkey (fixes ness-dev#4)
- useAliasForPath now accepts string | null | undefined; App.tsx window
  title read no longer needs the '?? ...' fallback subscription to a
  permanently-empty key (fixes ness-dev#7)
- MCP control-server response includes clamped: true when the caller's
  input differed from what got stored; the set_worktree_alias tool
  surfaces this to the agent as "input was normalized" so silent
  whitespace/length changes are discoverable (fixes ness-dev#8)
- Add control-server.test.ts covering POST/DELETE /aliases end-to-end:
  scope-fallback, clamp reporting, empty-clears-alias, and 400 on no
  worktreePath + no scope (fixes ness-dev#9)
- displayLabel docstring now explains the resolution order, the
  machine-facing exclusion, and the per-id selector pairing (fixes ness-dev#10)
frenchie4111 pushed a commit that referenced this pull request Jul 24, 2026
- Extract normalizeAlias() + ALIAS_MAX_LEN in shared/state/aliases.ts;
  IPC handler, MCP control-server dep, and control-server response
  echo all use it so the trim/clamp logic can't drift (fixes #2)
- useHotkeys skips when focused element (or ancestor) has
  data-hotkeys="ignore"; AliasEditor input opts in so Cmd+W / Cmd+K /
  etc. don't fire while renaming. Opt-in (not a general "skip inputs"
  rule) because xterm's hidden textarea would otherwise break every
  terminal-time hotkey (fixes #4)
- useAliasForPath now accepts string | null | undefined; App.tsx window
  title read no longer needs the '?? ...' fallback subscription to a
  permanently-empty key (fixes #7)
- MCP control-server response includes clamped: true when the caller's
  input differed from what got stored; the set_worktree_alias tool
  surfaces this to the agent as "input was normalized" so silent
  whitespace/length changes are discoverable (fixes #8)
- Add control-server.test.ts covering POST/DELETE /aliases end-to-end:
  scope-fallback, clamp reporting, empty-clears-alias, and 400 on no
  worktreePath + no scope (fixes #9)
- displayLabel docstring now explains the resolution order, the
  machine-facing exclusion, and the per-id selector pairing (fixes #10)
frenchie4111 added a commit that referenced this pull request Aug 11, 2026
One place that turns raw store state into the list model both the desktop
sidebar and the touch picker will render: per-repo sections, PR-status
groups (with assignedPRs passed, so phantom Reviewing rows exist), and
per-row status / pendingTool / shellActive / isMerged / snooze / alias /
repo label / Cmd-ordinal.

The builder is pure so grouping and aggregation are testable without a
renderer; useWorktreeListModel is the thin hook that feeds it from the
slices. Reads are hoisted to the list level on purpose — the row component
did per-row useAppState/useAliasForPath subscriptions, which is CLAUDE.md
anti-pattern #4 and would have doubled once mobile shared the row.

useWorktreeCollapse lifts the collapse/unified-repo preferences out of
App.tsx so both surfaces honour the same expansion state.
frenchie4111 added a commit that referenced this pull request Aug 15, 2026
)

## Summary

The desktop sidebar and the mobile worktree picker had drifted into two
independent implementations of the same screen. This collapses them onto
**one derivation** and **one list component**, so they can't disagree
again.

**Layer 1 — one derivation.** `worktree-list-model.ts` is now the only
place raw store state becomes a list. Mobile's `useAggregateStatuses` is
deleted, and so is `App.tsx`'s parallel derivation — it now reads
`statusByPath` / `pendingToolByPath` off the shared model, so the
sidebar, command center and command palette can't disagree about a
status. `worktree-row-style.ts` extracts `STATUS_COLORS`,
`STATUS_LABELS`, `detachedLikeTooltip()` and the 5-branch PR icon ladder
as pure functions, replacing three divergent copies.

**Layer 2 — one component.** `WorktreeList.tsx` owns everything inside
the scroll container: repo sections, collapsible group headers with
counts, rows, the inline continue-on-new-branch form, phantom
assigned-PR rows and pending-worktree rows. It takes `variant: 'desktop'
| 'touch'`.

**Layer 3 — shells stay separate,** deliberately. Desktop keeps its
fixed-width panel, resize handle, header, backend chip strip and footer;
mobile keeps its fullscreen overlay and header. These are genuinely
different surfaces.

### Bugs this fixes

- **Phantom "Reviewing" PRs were entirely missing on mobile** —
`groupWorktrees()` was called with 5 of its 6 arguments and the render
loop never mapped `group.phantomPRs`.
- **Mobile flattened every repo into one list**, ignoring the
`__unified__` sentinel and per-repo group-collapse scoping that desktop
had.
- **Status was derived twice, differently** — mobile's aggregation
dropped `pendingTool`, `shellActive` and `isMerged`.
- **Mobile was missing** aliases, snooze + wake-at, stale/prunable
badges, the PR-check color ladder, review-decision dots, and
continue-on-new-branch.

### Touch adaptations

Rows get a persistent `…` overflow button opening an action sheet
(chosen over long-press: discoverable, and no conflict with scroll
gestures). Right-click alias edit/clear folds into that same sheet.
Tooltips are disabled on touch, hotkey badges self-hide, and rows keep
44px tap targets regardless of the desktop `sidebarDensity` setting.

### Notes

- **Prop-explosion line** was drawn at the action cluster:
`DesktopRowActions` and `TouchRowActions` are separate presentational
leaves sharing `buildRowActions()`. That holds `WorktreeTab` to ~4
`variant` conditionals and `WorktreeList` to ~4.
- **Perf**: settings and meta-key reads are hoisted into `WorktreeList`
— one subscription for the whole list rather than N per row (CLAUDE.md
anti-pattern #4).
- **Deliberately not on mobile**: delete, prune, remove-repo,
new-worktree, dismiss-pending. Those handlers simply aren't passed, so
there are no destructive worktree operations from a phone. Assigned-PR
rows render informational there, since mobile has no new-worktree screen
to land on.

### User-visible changes

- Mobile gains collapsible group headers, phantom reviewing PRs,
aliases, snooze, stale badges, the PR color ladder and review-decision
dots. Header buttons went 32px → 44px.
- Desktop is visually unchanged except the stale badge, which moved
`text-[10px]` → `text-xs` per CLAUDE.md §11.
- The merge/split-repos mode now persists to the same `localStorage` key
on both platforms, so toggling it on one changes the other.

## Test plan

- [x] `npm run typecheck` clean
- [x] `npx electron-vite build` clean
- [x] `npx vitest run` — 2127 passed; the 8 failures are pre-existing
`src/main/` environment tests (git rebase/bisect timeouts, login-shell
PATH capture, `fs.watch` integration) and are unrelated
- [x] 44 new unit tests: 24 for the pure style functions incl. the
5-branch priority ladder, 20 for the derivation incl. the
previously-broken `phantomPRs` grouping path
- [x] Desktop driven at 1440×900 against a headless build: group counts,
repo labels in unified mode, aliases, PR numbers, hover actions,
right-click alias menu, group collapse
- [x] Mobile driven at 390×844: grouped picker, status dots, PR color
ladder, repo labels, aliases, per-row `…`, action sheet contents, no
hotkey badges
- [ ] **Not visually verified: the phantom review-PR rows.** They
deduped to zero on the test dataset — confirmed correct via `gh api
graphql` (all four review-requested PRs already have worktrees), so
`dedupPhantomPRs` was right to yield nothing. That render path is
covered by unit tests and by now sharing desktop's code path, but I
never saw a phantom row on a phone-width screen.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
frenchie4111 added a commit that referenced this pull request Oct 11, 2026
Two displays for work happening outside the main transcript.

Agents panel (new `agents` right-column panel). `collectAgents` in
`shared/state/json-claude-agents.ts` derives every sub-agent a session
launched — synchronous or background, at any nesting depth — from
entries + backgroundAgents. Nothing new goes over the wire. The panel
lists them running-first with a live "currently" line, elapsed time and
status; clicking one opens a peek flyout with its whole activity feed,
following the tail as it streams, and nested agents link through.

A dead agent is reported as `interrupted` rather than left spinning:
a sync sub-agent can't outlive its parent's turn, so no result once the
turn is over means the run was cut short.

Tasks tool (TaskCreate/TaskUpdate). Ness only knew TodoWrite, so a
session driving the newer Tasks tool showed an empty Todos panel and
raw JSON in the transcript. Unlike TodoWrite these mutate one task at a
time, so `collectTasks` replays the calls instead of reading the last
one — recovering ids from the create results, mirroring the
blocks/blockedBy graph, and surfacing owner and activeForm. The Todos
panel renders Tasks when present and falls back to TodoWrite; keeping
the `todos` panel key means saved per-repo panel config still applies.

Both panels derive off entry count rather than session identity, which
churns per streamed token — a full walk per token is CLAUDE.md
anti-pattern #4. The agents panel adds a 500ms tick for in-place
streaming updates, gated on something actually being live.

41 new reducer-level tests; full suite green.
frenchie4111 added a commit that referenced this pull request Oct 29, 2026
…nd (#334)

## What changed

**Nested tabs.** Sidebar worktree rows can list their tabs as indented
rows:
- Each row shows the tab's kind icon (Claude/Codex/Cursor, shell,
browser, diff, file, review), its live status (spinner while working,
yellow dot when waiting, pulsing red dot when it needs approval, spinner
for a busy shell) and its label. Asleep tabs are italic.
- Clicking a row opens that worktree and focuses the tab through the
existing `handleSelectTab`.
- The selected worktree's highlight now spans its row and its tab rows,
with a lighter highlight on the focused tab.
- Tabs are read through a new per-worktree `useWorktreePanes(path)`
selector, so one worktree's tab changes don't re-render the sidebar
(CLAUDE.md anti-pattern #4).
- The mobile picker and the collapsed sidebar are unchanged.

**Preference.** Settings → Appearance → Sidebar → "Worktree tabs in the
sidebar" offers **Never expanded**, **Only the selected is expanded**
(default) and **Expand all**. It's stored as
`settings.sidebarTabsExpansion` and is only written to `config.json`
when it isn't the default. Includes a reducer test.

**Switching lag.** Clicking a worktree used to render the sidebar
highlight in the same pass as the newly shown workspace and right
column. Now the sidebar reads a `useOptimistic` copy of the active
worktree, and the real switch runs in `startTransition`. To keep that
first render cheap, `WorktreeTab` and `RightColumn` are memoized with
reference-stable props: per-row action handlers are cached per row
object, and `RightColumn`'s inline callbacks are hoisted.

| Dev build, 10 sidebar clicks | Before | After |
|---|---|---|
| Highlight painted | 36–195 ms | 12–27 ms |
| Everything settled | — | 29–64 ms |

Keyboard switching (⌘1–9, next/previous worktree) still updates in one
pass. Moving it to the transition would make a held next-worktree key
compute "next" from a switch that hasn't committed yet, so it's left for
a follow-up.

The branch history includes a rounded "pill" selection style that was
tried and then dropped in favor of the edge-to-edge highlight.

## Testing
- `npm run typecheck`, `npx electron-vite build` and `npx vitest run`
(1848 tests) all pass.
- Checked in the dev app with screenshots and DOM checks taken over the
remote-debugging port:
- Clicking each preference option through the real Settings UI gives
Expand all → 31 of 32 rows expanded, Never → 0, Selected → 1.
- After rapid switching, the sidebar, main pane and right column end up
on the same worktree.
  - Clicking each nested tab focuses it in the tab strip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frenchie4111 added a commit that referenced this pull request Oct 29, 2026
…336)

Step 1 of the agent-reviewer work: Review viewer comments move out of
\`ReviewPane\` \`useState\` and into a persisted \`reviews\` slice.
Nothing agent-facing is built here. The aim is a data model that
main-side code (MCP tools later) can write to directly.

## Data model (\`src/shared/state/reviews.ts\`)

\`reviews.byPath[worktreePath].comments: ReviewComment[]\`, where each
comment has:

- \`id\`: a UUID generated in main
- \`filePath\`, \`side: 'old' | 'new'\`, and \`line\`, the current line,
valid at \`anchoredSha\`
- \`anchor: { commitSha, line, snippet }\`: fixed at creation. The
commit and line the comment was written against, plus the trimmed text
of that line
- \`author\`: \`{kind: 'human'}\` or \`{kind: 'agent', tabId, label?}\`.
Replies use the same author shape
- \`status: 'open' | 'resolved' | 'outdated'\`, plus \`body\`,
\`createdAt\`, and optional \`replies[]\`

Events: \`commentAdded\`, \`commentDeleted\`, \`commentStatusChanged\`,
\`replyAdded\` (reducer only, no UI yet), \`anchorsUpdated\` (batched),
\`worktreeRemoved\`. Single-item patches use \`findIndex\` + \`slice\`
(anti-pattern #3). The renderer reads through a per-worktree
\`useReviewComments(path)\` hook (anti-pattern #4).

The slice persists to \`config.reviews\` the same way
\`pinned\`/\`aliases\` do, is sanitized on boot, and loses a worktree's
entry when that worktree leaves the list.

**One correction to the brief:** the review diff never shows
working-tree content. Whole-branch mode is merge-base..**HEAD**
(\`getFileDiffSides(…, 'branch')\`), and commit and range modes are
commit-to-commit. So a comment always anchors to a real SHA: HEAD or the
selected tip for the new side, the diff base for the old side. There's
no "working tree vs committed" flag to record.

**Main computes the snippet itself** (\`src/main/review-comments.ts\`).
\`addReviewComment\` resolves the SHA for that side of the current view
and reads the line with \`git show\`. A caller only supplies \`{file,
line, side, body, view?}\`, which is the shape step 2's
\`add_review_comment\` MCP tool needs.

## Outdated detection (\`src/shared/review-anchor.ts\`)

There's no poller and no subscriber.
\`reviews:refreshAnchors(worktreePath, file?)\` runs when the Review
pane opens and when a file is selected:

1. One \`rev-parse HEAD\`. Comments whose \`anchoredSha\` is already
HEAD are skipped, so in the steady state there are no git reads and no
dispatches.
2. For stale new-side comments it reads each file once at HEAD and looks
for the snippet: first at the current line, then at the original line,
then the nearest exact match anywhere in the file. Snippets shorter than
4 characters (\`}\`) only match in place, so a comment can't jump to a
random brace.
3. If the snippet is found, the comment moves to that line, and an
\`outdated\` comment goes back to \`open\`. If it isn't found, the
comment becomes \`outdated\`. Resolved comments are left alone.
4. All changes go out as one \`anchorsUpdated\` event. Statuses are
recomputed from a fresh snapshot after the git awaits, so resolving a
comment while a refresh is in flight isn't undone.

Old-side comments point at the diff base, which doesn't move as the
author commits, so they are never outdated.

When drawing, \`ReviewDiffPane\` places each comment by snippet against
the content on screen. That way a commit or range view (whose content
differs from HEAD) still shows it on the right code. A comment it can't
place goes into a collapsible "N comments not on a line in this diff"
list above the diff, greyed out, and is never deleted. That covers
outdated comments, old-side comments (inline diff mode has no click
target for removed lines yet) and lines that don't exist in the selected
revision.

## Reviewed checkmarks stay per-viewer renderer state

The "viewed" checkmarks are my progress through *this* file set. They
still reset when the commit selection changes, because the progress is
relative to the files in view. Sharing them properly would need
content-hash invalidation (GitHub resets "viewed" when a file changes).
A review agent has no use for them either, so I left them out of the
slice.

## UI

- Each comment shows its author ("You", or the agent label), a
Resolved/Outdated tag, Resolve/Reopen and delete. Resolved and outdated
comments are dimmed, and outdated ones are struck through. Inline styles
stay rem-based (§11) and icons use \`icon-*\`.
- The file tree badge shows the open count. If a file has only resolved
or outdated comments, it shows a muted total instead.
- The header count shows open comments, with a tooltip for open comments
on files outside the current selection. Changing the selection no longer
discards comments.
- Send to Agent and Copy send **open comments only**, as \`path:line
@sha7 — body\`, plus replies. Old-side comments are labeled as a removed
line. Routing to the active tab is unchanged.
- Fixed a latent bug: the view-zone effect didn't depend on the editor
instance, so after a file switch the zones were drawn into the disposed
editor. With persisted comments, nothing would have shown after a
reload.

## Testing

- Reducer tests cover every event, including reference identity on
no-ops. There are also tests for anchor relocation and outdating, and
for \`formatReviewComments\`.
- \`typecheck\`, \`electron-vite build\` and \`vitest\` all pass.
- Manual check against a scratch repo in headless mode with an isolated
\`HARNESS_DATA_DIR\`:
  - added a comment; it reached config.json
- committed two lines above it, reloaded, and the comment moved from L4
to L6
- committed a removal of the line; it showed as Outdated above the diff
and Send was disabled
- in the single-commit view of the original commit, it showed inline on
the original line
  - Resolve and Reopen round-trip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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