Skip to content

Commit 2891a7d

Browse files
frenchie4111claude
andauthored
Tag other worktrees from the @ menu in Chat tabs (#257)
## Summary - The `@` menu in Chat tabs now lists other worktrees above the file matches, so you can point at one from the composer now that agents have `send_message`. - Picking a worktree inserts `@worktree:<branch>`. The `worktree:` prefix keeps the token from reading as a relative file path — a bare branch name sends the agent looking on disk before it guesses what you meant. `resolveWorktreeQuery` strips the prefix, so the token resolves whether the agent passes it verbatim or trims it first. - Rows only appear when the **worktree messaging** setting is on (default off) — that setting is what grants the agent `send_message` at all, so tagging a worktree without it would be a dead end. ## Notes Matching is substring rather than fuzzy. Worktree rows sort above file matches, so a loose subsequence hit — `@src` matching s…r…c somewhere inside a branch name — would take row 0 and steal the Enter key from the file the user was actually reaching for. Matching against the full `worktree:<branch>` label also means typing `@worktree` lists them all, which is how the feature gets discovered. Bare `@` with no query still shows files only, so the existing file-mention flow is untouched. ## Test plan - [x] `npm run typecheck` and `npx electron-vite build` clean - [x] New `resolveWorktreeQuery` cases for the `worktree:` prefix (`src/main/chat-delivery.test.ts`) - [x] Full `npx vitest run` — 8 failures are pre-existing environment-dependent tests (rebase state, `fs.watch`, login-shell PATH), untouched by this change - [ ] Manual: with worktree messaging on, type `@worktree` in a Chat tab and confirm the rows list, alias shows as the description, and picking one inserts `@worktree:<branch>` - [ ] Manual: with the setting off, confirm no worktree rows appear - [ ] Manual: confirm bare `@` still leads with files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3b3d25e commit 2891a7d

5 files changed

Lines changed: 114 additions & 10 deletions

File tree

resources/mcp-bridge.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ const TOOLS = [
253253
worktree: {
254254
type: 'string',
255255
description:
256-
'Which worktree to message — its display alias, its branch name, or its absolute path. Call list_worktrees first if you are unsure what exists.'
256+
'Which worktree to message — a `<repo>/<branch>` handle, its display alias, or its absolute path. A bare branch name works only when it is unique across every open repo, which `main` never is. A `@worktree:<repo>/<branch>` token in the user\'s message is a worktree mention picked from the composer; pass it here as-is (with or without the `worktree:` prefix). Call list_worktrees first if you are unsure what exists.'
257257
},
258258
message: {
259259
type: 'string',

src/main/chat-delivery.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ describe('resolveWorktreeQuery', () => {
110110
expect(resolveWorktreeQuery(state, 'feat/billing')).toEqual({ path: B })
111111
})
112112

113+
it('strips the worktree: prefix from a composer mention token', () => {
114+
expect(resolveWorktreeQuery(state, 'worktree:repo/feat/billing')).toEqual({ path: B })
115+
expect(resolveWorktreeQuery(state, 'Worktree: Auth Refactor')).toEqual({ path: A })
116+
})
117+
118+
it('disambiguates a branch shared across repos via the <repo>/<branch> handle', () => {
119+
const twoRepos = makeState({
120+
worktrees: [
121+
worktree(A, 'main', { repoRoot: '/src/harness' }),
122+
worktree(B, 'main', { repoRoot: '/src/chicken' })
123+
]
124+
})
125+
expect(resolveWorktreeQuery(twoRepos, 'harness/main')).toEqual({ path: A })
126+
expect(resolveWorktreeQuery(twoRepos, 'worktree:chicken/main')).toEqual({ path: B })
127+
// The bare branch is still ambiguous, and stays an error rather than a guess.
128+
expect(resolveWorktreeQuery(twoRepos, 'main')).toEqual({
129+
error: '"main" matches 2 worktrees — pass the absolute path instead'
130+
})
131+
})
132+
113133
it('errors on an unknown handle', () => {
114134
expect(resolveWorktreeQuery(state, 'nope')).toEqual({
115135
error: 'no worktree matching "nope"'

src/main/chat-delivery.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AppState } from '../shared/state'
22
import { getLeaves } from '../shared/state/terminals'
3+
import { worktreeHandle } from '../shared/state/worktrees'
34

45
export interface ChatDeliveryDeps {
56
/** Injects a user turn into a running json-mode chat session. */
@@ -56,15 +57,24 @@ function pickSleptTab(state: AppState, worktreePath: string): string | null {
5657
return fallback
5758
}
5859

60+
const WORKTREE_HANDLE_PREFIX = 'worktree:'
61+
5962
/** Resolve a caller-supplied worktree handle. Absolute path wins outright;
60-
* otherwise alias and branch are matched case-insensitively. An ambiguous
61-
* handle is an error rather than a guess — silently picking one of two
62-
* worktrees would deliver a message somewhere the sender didn't intend. */
63+
* otherwise a `<repo>/<branch>` handle, alias, and bare branch are matched
64+
* case-insensitively. An ambiguous handle is an error rather than a guess —
65+
* silently picking one of two worktrees would deliver a message somewhere
66+
* the sender didn't intend. */
6367
export function resolveWorktreeQuery(
6468
state: AppState,
6569
query: string
6670
): { path: string } | { error: string } {
67-
const q = query.trim()
71+
// The chat composer's @-mention inserts `@worktree:<repo>/<branch>`, and
72+
// agents routinely pass that token through verbatim. Strip the prefix so
73+
// it resolves instead of 404ing on a handle we handed them ourselves.
74+
let q = query.trim()
75+
if (q.toLowerCase().startsWith(WORKTREE_HANDLE_PREFIX)) {
76+
q = q.slice(WORKTREE_HANDLE_PREFIX.length).trim()
77+
}
6878
if (!q) return { error: 'worktree required' }
6979
// A prunable worktree's directory is already gone, so nothing can be
7080
// running in it to receive the message.
@@ -74,7 +84,11 @@ export function resolveWorktreeQuery(
7484
const matches = new Set<string>()
7585
for (const w of list) {
7686
const alias = state.aliases.byPath[w.path]
77-
if (alias?.toLowerCase() === lower || w.branch.toLowerCase() === lower) {
87+
if (
88+
alias?.toLowerCase() === lower ||
89+
w.branch.toLowerCase() === lower ||
90+
worktreeHandle(w.repoRoot, w.branch).toLowerCase() === lower
91+
) {
7892
matches.add(w.path)
7993
}
8094
}

src/renderer/components/JsonModeChat.tsx

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
GitBranchPlus
3535
} from 'lucide-react'
3636
import { openForkIntoWorktree } from './NewWorktreeScreen'
37-
import { useJsonClaudeSession, useSettings } from '../store'
37+
import { useAliases, useJsonClaudeSession, useSettings, useWorktrees } from '../store'
3838
import { useBackend } from '../backend'
3939
import { useJsonClaudeApprovals } from '../hooks/useJsonClaudeApprovals'
4040
import { JsonClaudeApprovalCard } from './JsonClaudeApprovalCard'
@@ -48,6 +48,7 @@ import { buildChildrenMap, isSubAgentToolName } from './json-mode-cards/grouping
4848
import { JsonModeMentionPopover, type MentionPopoverItem } from './JsonModeMentionPopover'
4949
import { JsonModeChatImageThumb } from './JsonModeChatImageThumb'
5050
import { fuzzyMatch } from '../fuzzy'
51+
import { worktreeHandle } from '../../shared/state/worktrees'
5152
import { CLAUDE_MODELS } from '../../shared/agent-registry'
5253
import {
5354
QUESTION_TOOL_NAME,
@@ -193,6 +194,49 @@ const FILE_CACHE = new Map<string, { files: string[]; ts: number }>()
193194
const FILE_CACHE_TTL_MS = 10_000
194195
const MAX_MENTION_RESULTS = 50
195196

197+
// `@worktree:<repo>/<branch>` is what a worktree mention inserts. The prefix
198+
// keeps the token from reading as a relative file path — without it the
199+
// agent tries to Read the branch name off disk before guessing it meant a
200+
// worktree. resolveWorktreeQuery (main/chat-delivery.ts) strips the same
201+
// prefix, so the token can be passed straight to send_message.
202+
const WORKTREE_MENTION_PREFIX = 'worktree:'
203+
const MAX_WORKTREE_MENTION_RESULTS = 5
204+
205+
// Substring rather than fuzzy: worktree rows sort above the file matches,
206+
// so a loose subsequence hit ("src" matching s…r…c somewhere in a branch)
207+
// would steal the default selection from the file the user was after.
208+
// Matching against the full `worktree:<branch>` label means typing
209+
// `@worktree` lists them all, which is how the feature is discovered.
210+
function matchWorktreeMentions(
211+
query: string,
212+
targets: { path: string; handle: string; alias?: string }[]
213+
): MentionPopoverItem[] {
214+
const q = query.trim().toLowerCase()
215+
if (!q) return []
216+
const ranked: { at: number; item: MentionPopoverItem }[] = []
217+
for (const t of targets) {
218+
const label = `${WORKTREE_MENTION_PREFIX}${t.handle}`
219+
const at = label.toLowerCase().indexOf(q)
220+
const aliasHit = t.alias?.toLowerCase().includes(q) ?? false
221+
if (at === -1 && !aliasHit) continue
222+
ranked.push({
223+
at: at === -1 ? Number.MAX_SAFE_INTEGER : at,
224+
item: {
225+
key: `worktree:${t.path}`,
226+
label,
227+
labelMatchIndices:
228+
at === -1
229+
? undefined
230+
: Array.from({ length: q.length }, (_, i) => at + i),
231+
description: t.alias,
232+
icon: <GitBranch className="icon-xs" />
233+
}
234+
})
235+
}
236+
ranked.sort((a, b) => a.at - b.at)
237+
return ranked.slice(0, MAX_WORKTREE_MENTION_RESULTS).map((r) => r.item)
238+
}
239+
196240
// Pre-baked descriptions for built-in slash commands. Skills + plugin
197241
// commands appear in the menu via session.slashCommands (sourced from
198242
// claude's system/init event) but don't have a description until we
@@ -1185,8 +1229,11 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
11851229
jsonModeSendOnEnter: sendOnEnter,
11861230
autoScrollToBottom,
11871231
defaultClaudeTabType,
1188-
conversationForkEnabled
1232+
conversationForkEnabled,
1233+
worktreeMessagingEnabled
11891234
} = useSettings()
1235+
const worktrees = useWorktrees()
1236+
const aliases = useAliases()
11901237
const cameFromTerminalDefault = defaultClaudeTabType === 'xterm'
11911238
const isMac =
11921239
typeof window !== 'undefined' &&
@@ -1918,6 +1965,19 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
19181965

19191966
const currentModelDisplay = claudeModelDisplayName(session?.currentModel)
19201967

1968+
// Gated on the setting that grants the agent send_message in the first
1969+
// place — tagging a worktree it has no tool to reach is a dead end.
1970+
const worktreeMentionTargets = useMemo(() => {
1971+
if (!worktreeMessagingEnabled) return []
1972+
return worktrees.list
1973+
.filter((w) => !w.prunable && w.path !== worktreePath)
1974+
.map((w) => ({
1975+
path: w.path,
1976+
handle: worktreeHandle(w.repoRoot, w.branch),
1977+
alias: aliases.byPath[w.path]
1978+
}))
1979+
}, [worktreeMessagingEnabled, worktrees.list, aliases.byPath, worktreePath])
1980+
19211981
const mentionItems = useMemo<MentionPopoverItem[]>(() => {
19221982
if (mentionDismissed === draft) return []
19231983
if (modelPickerTrigger !== null) {
@@ -2002,7 +2062,7 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
20022062
}
20032063
})
20042064
}
2005-
if (mentionTrigger !== null && files.length > 0) {
2065+
if (mentionTrigger !== null) {
20062066
const q = mentionTrigger.query
20072067
let ranked: { item: string; indices?: number[] }[]
20082068
if (q.length === 0) {
@@ -2012,19 +2072,21 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
20122072
.slice(0, MAX_MENTION_RESULTS)
20132073
.map((r) => ({ item: r.item, indices: r.indices }))
20142074
}
2015-
return ranked.map((r) => ({
2075+
const fileRows = ranked.map((r) => ({
20162076
key: r.item,
20172077
label: r.item,
20182078
labelMatchIndices: r.indices,
20192079
icon: <FileText className="icon-xs" />
20202080
}))
2081+
return [...matchWorktreeMentions(q, worktreeMentionTargets), ...fileRows]
20212082
}
20222083
return []
20232084
}, [
20242085
slashTrigger,
20252086
mentionTrigger,
20262087
modelPickerTrigger,
20272088
files,
2089+
worktreeMentionTargets,
20282090
draft,
20292091
mentionDismissed,
20302092
session?.slashCommands,

src/shared/state/worktrees.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export interface Worktree {
2323
prunableReason?: string
2424
}
2525

26+
/** Handle for a worktree in agent-facing text: `<repo>/<branch>`. Branch
27+
* alone collides the moment two repos are open — every repo has a `main` —
28+
* and an ambiguous handle is a hard error at resolve time, not a guess.
29+
* Shared so the composer's @-mention and resolveWorktreeQuery agree. */
30+
export function worktreeHandle(repoRoot: string, branch: string): string {
31+
return `${repoRoot.split('/').pop() || repoRoot}/${branch}`
32+
}
33+
2634
/** Merge per-repo `listWorktrees` results into a flat list, preserving the
2735
* caller's prior slice for any repo whose lookup failed (indicated by null).
2836
* Purpose: a transient `git worktree list` failure for one repo shouldn't

0 commit comments

Comments
 (0)