Skip to content

Commit 88e7325

Browse files
frenchie4111claude
andauthored
Small UX tweaks and a PTY resume fix (#6)
* Make new worktree form more visible Accent-colored top border, bg-panel-raised container, label, and primary-styled Create button so cmd+N isn't easy to miss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Open link to PR status header Surfaces the PR URL directly so users don't need to remember the open-PR hotkey. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add working/branch toggle to Changed Files panel New segmented icon toggle switches between uncommitted working-tree changes and the base...HEAD branch diff (same files a PR would show). Base branch is auto-detected from origin/HEAD with fallbacks to origin/main, origin/master, main, master. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Bump package-lock.json version during release Keeps the lockfile's root version in sync with package.json so each release commit doesn't leave a dangling lockfile drift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add tooltips to close-tab and group-collapse buttons The only two icon-only buttons in the app that were missing titles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Stop focus reports from leaking into resumed PTYs Saved scrollback can contain CSI ?1004h (enable focus reporting). When that history was replayed on session resume, xterm would enable focus reporting and then emit focus-out (ESC [ O) / focus-in (ESC [ I) sequences into the freshly spawned Claude process, leaving a stray "O" in Claude's input. Fix: use the callback form of terminal.write so onData / PTY spawn are deferred until history parsing finishes, then explicitly reset focus, mouse, and bracketed-paste reporting modes before starting the new process. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Surface merge conflicts in PR status panel Fetch PR detail alongside check runs to pick up mergeable / mergeable_state, then render a danger-colored "Merge conflict" line above the checks row when GitHub reports mergeable_state: dirty (or mergeable === false). Also promote conflicted PRs into the "Needs Attention" sidebar group. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Tint worktree PR icon red on merge conflict Conflicted PRs already land in Needs Attention, but the inline PR icon in the worktree row was still colored by check status. Treat a merge conflict as an error so the icon turns red too, and mention it in the tooltip. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Surface check failure details in PR status panel Expose output.summary and details_url/target_url from GitHub's check runs and statuses, then use them to cut clicks when a check fails: - Auto-expand the check list when checksOverall is 'failure' so the user sees which check broke without expanding manually. - Show a one-line failure reason (output.title, falling back to the first meaningful line of output.summary) under each failed check. - Clicking any check row with a details URL opens the CI log page in the browser via openExternal, with an ExternalLink icon on hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Open in Editor feature New Editor section in Settings lets the user pick a preferred GUI editor (VS Code, Cursor, Windsurf, Zed, Sublime, and the JetBrains lineup). The editor's CLI is spawned via a login shell so homebrew paths are picked up. Triggers: - Code2 icon pinned to the far right of the terminal tab bar opens the active worktree in the configured editor. - Per-file Code2 icon on hover in the Changed Files panel opens a specific file in the worktree context. - Cmd+Shift+E hotkey (rebindable) opens the active worktree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix status dot stuck on needs-approval after tool use The hook script wrote status via 'echo {...} > file', which truncates then writes. fs.watch on macOS can fire the callback during the tiny window when the file is truncated but the new content hasn't landed, causing JSON.parse to throw on an empty file. The dropped read meant the needs-approval -> processing transition after a user approved a tool use sometimes never reached the renderer and the dot stayed red. Fix: make the hook write atomically — printf into a temp file, then mv -f into place. The watcher only ever sees a fully-formed target file. Bumped HARNESS_HOOK_VERSION so installed worktrees get the new script. Added a small retry-on-parse-error in the watcher as defense in depth, and a guard so the temp files we create don't trigger the watcher themselves. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Make Shift+Enter insert a newline in Claude terminals xterm.js sends bare \r for both Enter and Shift+Enter, so Claude Code could not distinguish them and treated every Shift+Enter as submit. Intercept Shift+Enter in the custom key event handler and write '\\\r' (backslash + CR) directly to the PTY — this matches Claude Code's documented line-continuation pattern and inserts a newline regardless of cursor position. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Replace native title tooltips with Radix tooltips + hotkey hints Native title tooltips have a ~500ms delay, can't be styled, and give no way to teach the user the keyboard shortcut that drives the same button. Swap them out for @radix-ui/react-tooltip wrapped in a thin Tooltip component with a matching API (label, side, optional action). - Radix handles collision detection, auto-flipping at viewport edges, and sideOffset so tooltips don't appear on top of the target. - delayDuration=0 for instant show, skipDelayDuration=0 so moving between adjacent buttons stays snappy. - A HotkeysProvider context wraps the app and the Settings screen, exposing the resolved hotkey map (defaults + user overrides). When a Tooltip is given an action prop it renders the current binding in a mono chip with mac glyphs (⌘⇧⌥⌃) so users discover shortcuts without opening Settings. - Swept every icon button across Sidebar, TerminalPanel, ChangedFilesPanel, PRStatusPanel, WorktreeTab, and the Settings hotkey reset button. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Branch new worktrees off the latest remote main Previously 'git worktree add -b <name>' with no explicit base used whatever was checked out in the main repo, which meant new worktrees inherited stale local main or whatever branch happened to be checked out. Now the default is to fetch origin and branch from origin/<default>, falling back to local HEAD if the fetch fails (offline, no remote, etc.). Added a worktreeBase setting ('remote' | 'local', default 'remote') with a new Worktrees section in Settings explaining the tradeoff: remote is always up-to-date but costs a fetch, local is faster but inherits whatever is locally checked out. getDefaultBaseRef is now exported from worktree.ts and reused for both the Changed Files branch-diff mode and worktree creation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Auto-focus the active terminal when switching worktrees Switching worktrees previously dropped keyboard focus into nothing, so the first keystroke after a switch would get lost. Add a useEffect that runs on activeWorktreeId / activeTabId changes and schedules a focusTerminalById on the next frame (so the TerminalPanel's display:none → display:block swap has already happened). Skips diff tabs since those are read-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7796043 commit 88e7325

23 files changed

Lines changed: 1458 additions & 172 deletions

package-lock.json

Lines changed: 450 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
}
9191
},
9292
"dependencies": {
93+
"@radix-ui/react-tooltip": "^1.2.8",
9394
"@xterm/addon-fit": "^0.11.0",
9495
"@xterm/addon-serialize": "^0.14.0",
9596
"@xterm/addon-webgl": "^0.19.0",

scripts/release.sh

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,21 @@ if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
104104
fi
105105

106106
# ---- Bump version ----
107-
step "Bumping package.json to ${VERSION}"
107+
step "Bumping package.json and package-lock.json to ${VERSION}"
108108
node -e "
109109
const fs = require('fs');
110+
const v = '${VERSION}';
110111
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
111-
pkg.version = '${VERSION}';
112+
pkg.version = v;
112113
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
114+
if (fs.existsSync('package-lock.json')) {
115+
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf-8'));
116+
lock.version = v;
117+
if (lock.packages && lock.packages['']) lock.packages[''].version = v;
118+
fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n');
119+
}
113120
"
114-
ok "package.json updated"
121+
ok "package.json and package-lock.json updated"
115122

116123
# ---- Update README and landing page download links ----
117124
step "Updating download links in README and docs/index.html"
@@ -130,7 +137,7 @@ for (const f of files) {
130137
"
131138
ok "Download links updated"
132139

133-
git add package.json README.md docs/index.html
140+
git add package.json package-lock.json README.md docs/index.html
134141
git commit -m "Release v${VERSION}"
135142
ok "Committed version bump and download link updates"
136143

src/main/editor.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { spawn } from 'child_process'
2+
import { join, isAbsolute } from 'path'
3+
import { log } from './debug'
4+
5+
export interface EditorDef {
6+
id: string
7+
name: string
8+
/** Shell command used to launch the editor. Must be on the user's PATH. */
9+
cmd: string
10+
}
11+
12+
/** Known GUI editors. Each is invoked as `<cmd> <worktreePath> [<filePath>]`,
13+
* which is the universal form for VS Code-family editors, Zed, Sublime Text,
14+
* and the JetBrains launchers. The command must be installed on the user's
15+
* PATH — we spawn via a login shell so homebrew/nvm/etc. are picked up. */
16+
export const AVAILABLE_EDITORS: EditorDef[] = [
17+
{ id: 'vscode', name: 'VS Code', cmd: 'code' },
18+
{ id: 'cursor', name: 'Cursor', cmd: 'cursor' },
19+
{ id: 'windsurf', name: 'Windsurf', cmd: 'windsurf' },
20+
{ id: 'zed', name: 'Zed', cmd: 'zed' },
21+
{ id: 'sublime', name: 'Sublime Text', cmd: 'subl' },
22+
{ id: 'idea', name: 'IntelliJ IDEA', cmd: 'idea' },
23+
{ id: 'webstorm', name: 'WebStorm', cmd: 'webstorm' },
24+
{ id: 'pycharm', name: 'PyCharm', cmd: 'pycharm' },
25+
{ id: 'goland', name: 'GoLand', cmd: 'goland' },
26+
{ id: 'rubymine', name: 'RubyMine', cmd: 'mine' },
27+
{ id: 'rustrover', name: 'RustRover', cmd: 'rustrover' },
28+
{ id: 'rider', name: 'Rider', cmd: 'rider' }
29+
]
30+
31+
export const DEFAULT_EDITOR_ID = 'vscode'
32+
33+
function findEditor(id: string): EditorDef | null {
34+
return AVAILABLE_EDITORS.find((e) => e.id === id) || null
35+
}
36+
37+
/** Shell-escape a single argument for use inside zsh -ilc. */
38+
function shellEscape(s: string): string {
39+
return `'${s.replace(/'/g, `'\\''`)}'`
40+
}
41+
42+
/** Launch the configured editor, opening `worktreePath`. If `filePath` is
43+
* given (relative to the worktree), the file is also opened. Spawns
44+
* detached so the editor outlives the renderer and doesn't block. */
45+
export function openInEditor(
46+
editorId: string,
47+
worktreePath: string,
48+
filePath?: string
49+
): { ok: true } | { ok: false; error: string } {
50+
const editor = findEditor(editorId)
51+
if (!editor) return { ok: false, error: `Unknown editor: ${editorId}` }
52+
53+
const args: string[] = [worktreePath]
54+
if (filePath) {
55+
args.push(isAbsolute(filePath) ? filePath : join(worktreePath, filePath))
56+
}
57+
58+
const shellCmd = `${editor.cmd} ${args.map(shellEscape).join(' ')}`
59+
log('editor', `launching ${editor.id}: ${shellCmd}`)
60+
61+
try {
62+
const child = spawn('/bin/zsh', ['-ilc', shellCmd], {
63+
detached: true,
64+
stdio: 'ignore'
65+
})
66+
child.on('error', (err) => log('editor', `spawn error: ${err.message}`))
67+
child.unref()
68+
return { ok: true }
69+
} catch (err) {
70+
const msg = err instanceof Error ? err.message : String(err)
71+
log('editor', `failed to spawn ${editor.cmd}: ${msg}`)
72+
return { ok: false, error: msg }
73+
}
74+
}

src/main/github.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface CheckStatus {
99
name: string
1010
state: 'success' | 'failure' | 'pending' | 'neutral' | 'skipped' | 'error'
1111
description: string
12+
/** Longer failure summary from the check's output (markdown, may be multi-line) */
13+
summary?: string
14+
/** External URL to the check's log / details page */
15+
detailsUrl?: string
1216
}
1317

1418
export interface PRStatus {
@@ -19,6 +23,8 @@ export interface PRStatus {
1923
branch: string
2024
checks: CheckStatus[]
2125
checksOverall: 'success' | 'failure' | 'pending' | 'none'
26+
/** true = has conflicts with base, false = mergeable, null = still computing */
27+
hasConflict: boolean | null
2228
}
2329

2430
function getToken(): string | null {
@@ -92,11 +98,18 @@ interface ApiPR {
9298
head: { ref: string; sha: string }
9399
}
94100

101+
interface ApiPRDetail extends ApiPR {
102+
mergeable: boolean | null
103+
mergeable_state: string
104+
}
105+
95106
interface ApiCheckRun {
96107
name: string
97108
status: 'queued' | 'in_progress' | 'completed' | 'waiting' | 'requested' | 'pending'
98109
conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null
99-
output?: { title?: string | null }
110+
html_url?: string | null
111+
details_url?: string | null
112+
output?: { title?: string | null; summary?: string | null }
100113
}
101114

102115
interface ApiCheckRunsResponse {
@@ -108,6 +121,7 @@ interface ApiStatus {
108121
state: 'error' | 'failure' | 'pending' | 'success'
109122
context: string
110123
description: string | null
124+
target_url: string | null
111125
}
112126

113127
interface ApiCombinedStatus {
@@ -185,25 +199,40 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
185199
const pr = prList[0]
186200
const sha = pr.head.sha
187201

188-
// Fetch check runs AND status contexts for the SHA in parallel
189-
const [checkRunsRes, combinedRes] = await Promise.all([
202+
// Fetch check runs, status contexts, and PR detail (for mergeable) in parallel.
203+
// The /pulls/{n} endpoint triggers GitHub's background mergeability computation
204+
// and returns the result if it's ready — otherwise mergeable is null.
205+
const [checkRunsRes, combinedRes, prDetail] = await Promise.all([
190206
githubFetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`) as Promise<ApiCheckRunsResponse>,
191-
githubFetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status`) as Promise<ApiCombinedStatus>
207+
githubFetch(`https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status`) as Promise<ApiCombinedStatus>,
208+
githubFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}`) as Promise<ApiPRDetail>
192209
])
193210

211+
// mergeable_state 'dirty' is the definitive conflict signal. mergeable===false
212+
// alone can also indicate conflicts. Null/unknown means GitHub hasn't finished
213+
// computing yet, so we report null and the UI hides the conflict indicator.
214+
let hasConflict: boolean | null
215+
if (prDetail.mergeable_state === 'dirty') hasConflict = true
216+
else if (prDetail.mergeable === false) hasConflict = true
217+
else if (prDetail.mergeable === true) hasConflict = false
218+
else hasConflict = null
219+
194220
const checks: CheckStatus[] = []
195221
for (const run of checkRunsRes.check_runs || []) {
196222
checks.push({
197223
name: run.name,
198224
state: normalizeCheckState(run.status, run.conclusion),
199-
description: run.output?.title || ''
225+
description: run.output?.title || '',
226+
summary: run.output?.summary || undefined,
227+
detailsUrl: run.html_url || run.details_url || undefined
200228
})
201229
}
202230
for (const s of combinedRes.statuses || []) {
203231
checks.push({
204232
name: s.context,
205233
state: normalizeStatusState(s.state),
206-
description: s.description || ''
234+
description: s.description || '',
235+
detailsUrl: s.target_url || undefined
207236
})
208237
}
209238

@@ -221,7 +250,8 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
221250
url: pr.html_url,
222251
branch: branchName,
223252
checks,
224-
checksOverall: computeOverall(checks)
253+
checksOverall: computeOverall(checks),
254+
hasConflict
225255
}
226256
} catch (err) {
227257
log('github', `getPRStatus failed for ${branchName}`, err instanceof Error ? err.message : err)

src/main/hooks.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,29 @@ import { log } from './debug'
77
const STATUS_DIR = '/tmp/harness-status'
88
const HARNESS_HOOK_MARKER = '__claude_harness__'
99
// Bump this when the hook format changes to force reinstallation
10-
const HARNESS_HOOK_VERSION = 4
10+
const HARNESS_HOOK_VERSION = 5
1111

1212
// Per-event hook commands. Each event type gets its own simple command
1313
// that writes the appropriate status. No jq dependency — these don't need
1414
// to parse stdin, they just know what event they're attached to.
1515
// Uses CLAUDE_HARNESS_ID env var (set by our PTY manager) to key the file.
16+
//
17+
// The write is atomic: content goes to a temp file and is then renamed over
18+
// the target. fs.watch on macOS otherwise fires mid-write during the
19+
// truncate-then-write window, which caused status transitions to be lost
20+
// (JSON.parse on an empty file). With rename the watcher only ever sees the
21+
// completed file.
1622
function makeHookCommand(status: string): string {
17-
return (
18-
'bash -c \'hid="$CLAUDE_HARNESS_ID"; [ -z "$hid" ] && exit 0; ' +
19-
'mkdir -p ' + STATUS_DIR + '; ' +
20-
'echo "{\\"status\\":\\"' + status + '\\",\\"ts\\":$(date +%s)}" > ' + STATUS_DIR + '/$hid.json\''
21-
)
23+
const payload = `{"status":"${status}","ts":%s}`
24+
// Inner bash script. Single quotes inside a single-quoted shell string are
25+
// escaped via the classic '\'' dance (close, literal quote, reopen).
26+
const inner =
27+
`hid="$CLAUDE_HARNESS_ID"; [ -z "$hid" ] && exit 0; ` +
28+
`d=${STATUS_DIR}; mkdir -p "$d"; ` +
29+
`t="$d/.$hid.$$.tmp"; ` +
30+
`printf '\\''${payload}'\\'' "$(date +%s)" > "$t" && ` +
31+
`mv -f "$t" "$d/$hid.json"`
32+
return `bash -c '${inner}'`
2233
}
2334

2435
// For Notification we need to distinguish idle_prompt vs permission_prompt.
@@ -141,24 +152,36 @@ export function watchStatusDir(
141152

142153
const watcher = watch(STATUS_DIR, (eventType, filename) => {
143154
if (!filename || !filename.endsWith('.json')) return
155+
// Skip the atomic-rename temp files the hook writes (".<id>.<pid>.tmp")
156+
if (filename.startsWith('.')) return
144157
const terminalId = filename.replace('.json', '')
145158
const win = getWindowForTerminal(terminalId)
146159
if (!win) {
147160
log('hooks', `status file changed for unknown terminal: ${filename}`)
148161
return
149162
}
150163

151-
try {
152-
const raw = readFileSync(join(STATUS_DIR, filename), 'utf-8')
153-
const data = JSON.parse(raw)
154-
const status = data.status as PtyStatus
155-
log('hooks', `status update: terminal=${terminalId} status=${status}`, data)
156-
if (status) {
157-
win.webContents.send('terminal:status', terminalId, status)
164+
// Read with a small retry in case we race the writer (shouldn't happen
165+
// now that hooks write via rename, but cheap insurance against future
166+
// hook-script regressions).
167+
const tryRead = (attempt: number): void => {
168+
try {
169+
const raw = readFileSync(join(STATUS_DIR, filename), 'utf-8')
170+
const data = JSON.parse(raw)
171+
const status = data.status as PtyStatus
172+
log('hooks', `status update: terminal=${terminalId} status=${status}`, data)
173+
if (status) {
174+
win.webContents.send('terminal:status', terminalId, status)
175+
}
176+
} catch (err) {
177+
if (attempt < 2) {
178+
setTimeout(() => tryRead(attempt + 1), 25)
179+
} else {
180+
log('hooks', `failed to read status file after retries: ${filename}`, err instanceof Error ? err.message : err)
181+
}
158182
}
159-
} catch (err) {
160-
log('hooks', `failed to read status file: ${filename}`, err instanceof Error ? err.message : err)
161183
}
184+
tryRead(0)
162185
})
163186

164187
return () => watcher.close()

0 commit comments

Comments
 (0)