Phase 19 delivers six improvements spanning naming theming, merge conflict safety, cross-worktree merge defaults, todo list visual polish, per-worktree model persistence, and tab context menus. It includes: replacing the world-cities naming list with dog breeds for worktree branch names; showing a merge conflicts section in the changes sidebar and disabling commit when conflicts exist; auto-defaulting the merge dropdown to the most recently committed branch across sibling worktrees in the same project; replacing text priority labels with Jira-style chevron icons in the todo list tool view; persisting the last-used model per worktree so new tabs inherit the worktree's model rather than a global default; and adding right-click context menus to session and file tabs with close/copy-path actions.
- Replace world cities with dog breeds for worktree branch naming
- Show merge conflicts section in changes sidebar and disable commit button when conflicts exist
- Default the merge dropdown to the most recently committed branch across sibling worktrees
- Replace todo priority text labels with Jira-style chevron icons
- Persist last-used model per worktree (not globally) so new tabs default to it
- Add right-click context menus to session and file tabs
| Component | Technology |
|---|---|
| Dog breed names | Replace CITY_NAMES array and all references in city-names.ts, rename file to breed-names.ts |
| Merge conflict UX | Extend ChangesView with conflicts section, update GitCommitForm disable logic |
| Cross-worktree merge default | New defaultMergeBranch state in useGitStore, set on commit, read by sibling worktrees |
| Todo chevron icons | Replace PriorityBadge text with ChevronUp/ChevronDown/ChevronsUp icons from lucide-react |
| Per-worktree model | New model_* columns on worktrees table, update useSessionStore.createSession cascade |
| Tab context menus | New TabContextMenu component using shadcn ContextMenu, close/copy-path actions |
Worktree branch names are generated from a list of ~130 world city names in src/main/services/city-names.ts. The CITY_NAMES array (lines 5-135) is used by:
getRandomCityName()(line 140) -- picks a random cityselectUniqueCityName(existingNames)(line 149) -- picks a unique name, falls back to-v1suffixsrc/main/services/git-service.ts(line 229-270) -- callsselectUniqueCityNameduring worktree creationsrc/main/services/opencode-service.ts(line 1096-1130) -- checksCITY_NAMES.some(...)to detect auto-named branches for auto-renamesrc/main/ipc/worktree-handlers.ts(line 211-221) -- checks city names during worktree sync to update display names
The names are used as git branch names directly, so they must be valid branch names (lowercase, no spaces, no special characters).
Naming change: cities → dog breeds
Before: tokyo, paris, chicago, mumbai, ...
After: golden-retriever, labrador, beagle, husky, ...
All names must be:
- Valid git branch names (lowercase, hyphens allowed)
- Memorable and fun
- No duplicates in the list
- 100+ entries for variety
File rename:
city-names.ts → breed-names.ts
Export rename:
CITY_NAMES → BREED_NAMES
getRandomCityName → getRandomBreedName
selectUniqueCityName → selectUniqueBreedName
All references must be updated across the codebase.
A. Create breed-names.ts (rename city-names.ts):
Replace the CITY_NAMES array with BREED_NAMES containing 120+ dog breeds, all formatted as valid git branch names:
export const BREED_NAMES = [
// Sporting Group
'golden-retriever',
'labrador',
'cocker-spaniel',
'english-setter',
'irish-setter',
'gordon-setter',
'brittany',
'vizsla',
'weimaraner',
'german-shorthaired-pointer',
'english-springer-spaniel',
'welsh-springer-spaniel',
'nova-scotia-duck-tolling-retriever',
'chesapeake-bay-retriever',
'flat-coated-retriever',
'boykin-spaniel',
'clumber-spaniel',
'field-spaniel',
'irish-water-spaniel',
'lagotto-romagnolo',
// Hound Group
'beagle',
'basset-hound',
'dachshund',
'bloodhound',
'greyhound',
'whippet',
'afghan-hound',
'saluki',
'borzoi',
'rhodesian-ridgeback',
'basenji',
'irish-wolfhound',
'scottish-deerhound',
'coonhound',
'foxhound',
'harrier',
'otterhound',
'petit-basset-griffon-vendeen',
'pharaoh-hound',
'ibizan-hound',
// Working Group
'boxer',
'rottweiler',
'doberman',
'great-dane',
'mastiff',
'bernese-mountain-dog',
'newfoundland',
'saint-bernard',
'siberian-husky',
'alaskan-malamute',
'samoyed',
'akita',
'great-pyrenees',
'portuguese-water-dog',
'bullmastiff',
'cane-corso',
'dogue-de-bordeaux',
'giant-schnauzer',
'leonberger',
'tibetan-mastiff',
// Terrier Group
'bull-terrier',
'airedale',
'scottish-terrier',
'west-highland-terrier',
'cairn-terrier',
'yorkshire-terrier',
'jack-russell',
'fox-terrier',
'border-terrier',
'staffordshire-terrier',
'miniature-schnauzer',
'soft-coated-wheaten',
'bedlington-terrier',
'irish-terrier',
'kerry-blue-terrier',
'norwich-terrier',
'norfolk-terrier',
'welsh-terrier',
'sealyham-terrier',
'lakeland-terrier',
// Toy Group
'chihuahua',
'pomeranian',
'maltese',
'shih-tzu',
'pug',
'cavalier-king-charles',
'papillon',
'havanese',
'pekingese',
'italian-greyhound',
'chinese-crested',
'japanese-chin',
'toy-fox-terrier',
'affenpinscher',
'brussels-griffon',
// Herding Group
'border-collie',
'german-shepherd',
'australian-shepherd',
'corgi',
'shetland-sheepdog',
'old-english-sheepdog',
'belgian-malinois',
'rough-collie',
'australian-cattle-dog',
'cardigan-welsh-corgi',
'bouvier-des-flandres',
'briard',
'canaan-dog',
'beauceron',
'bergamasco',
// Non-Sporting Group
'poodle',
'dalmatian',
'bulldog',
'french-bulldog',
'boston-terrier',
'shiba-inu',
'chow-chow',
'lhasa-apso',
'bichon-frise',
'keeshond',
'schipperke',
'tibetan-spaniel',
'tibetan-terrier',
'finnish-spitz',
'xoloitzcuintli'
]B. Rename exports:
export function getRandomBreedName(): string {
const index = Math.floor(Math.random() * BREED_NAMES.length)
return BREED_NAMES[index]
}
export function selectUniqueBreedName(existingNames: Set<string>): string {
const MAX_ATTEMPTS = 10
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const breedName = getRandomBreedName()
if (!existingNames.has(breedName)) {
return breedName
}
}
const baseName = getRandomBreedName()
let version = 1
let candidateName = `${baseName}-v${version}`
while (existingNames.has(candidateName)) {
version++
candidateName = `${baseName}-v${version}`
}
return candidateName
}C. Update all references:
src/main/services/index.ts-- update re-export frombreed-namessrc/main/services/git-service.ts-- import and callselectUniqueBreedNamesrc/main/services/opencode-service.ts-- importBREED_NAMES, updateCITY_NAMES.some(...)toBREED_NAMES.some(...)src/main/ipc/worktree-handlers.ts-- importBREED_NAMES, update city name detection
D. Backward compatibility: The auto-rename check in opencode-service.ts should detect BOTH old city names and new breed names. Keep CITY_NAMES as a deprecated export (or inline the old array) in the detection logic so existing worktrees with city-name branches still get auto-renamed correctly:
// In opencode-service.ts auto-rename logic:
const isAutoName =
BREED_NAMES.some((b) => branchName === b || branchName.startsWith(`${b}-v`)) ||
LEGACY_CITY_NAMES.some((c) => branchName === c || branchName.startsWith(`${c}-v`))| File | Change |
|---|---|
src/main/services/city-names.ts |
Rename to breed-names.ts, replace array + exports |
src/main/services/index.ts |
Update re-export path |
src/main/services/git-service.ts |
Import selectUniqueBreedName instead of selectUniqueCityName |
src/main/services/opencode-service.ts |
Import BREED_NAMES, update auto-rename detection, add backward compat for city names |
src/main/ipc/worktree-handlers.ts |
Import BREED_NAMES, update display name sync logic |
test/phase-11/session-4/auto-rename-branch.test.ts |
Update imports and test data |
test/phase-11/session-3/branch-rename-infra.test.ts |
Update imports |
test/phase-11/session-12/integration-verification.test.ts |
Update imports and test data |
test/session-5/worktrees.test.tsx |
Update test city name references |
The ChangesView component (src/renderer/src/components/file-tree/ChangesView.tsx, lines 88-110) groups files into stagedFiles, modifiedFiles, and untrackedFiles. Files with status 'C' (conflicted) are silently dropped -- they don't match any of the three filter conditions (staged, '?', 'M'/'D'/'A').
The GitStatusPanel component (the older alternative) does handle conflicts -- it has a dedicated "Conflicts" section (lines 481-495) and an orange "CONFLICTS" button. But ChangesView is the one rendered in the right sidebar via FileSidebar.
The commit button in GitCommitForm.tsx (line 71) is enabled when hasStaged && hasSummary && !isCommitting. There is no conflict check.
The useGitStore already tracks conflicts via conflictsByWorktree: Record<string, boolean> (line 34) and updates this on status load (line 117-128). The header already has a "Fix conflicts" button (line 83-86 of Header.tsx).
Merge conflicts in ChangesView:
When conflicted files exist:
┌──────────────────────────────────────────────────┐
│ Changes │
│ │
│ ▼ Merge Conflicts (2) ← NEW SECTION │
│ ⚠ src/main/index.ts C │
│ ⚠ src/renderer/app.tsx C │
│ │
│ ▼ Staged Changes (3) │
│ ✓ file1.ts M │
│ ✓ file2.ts A │
│ ✓ file3.ts D │
│ │
│ ▼ Changes (1) │
│ ... │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Summary: [___________________________] │ │
│ │ │ │
│ │ [Commit (3 files)] ← DISABLED │ │
│ │ "Resolve merge conflicts before committing" │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
Changes:
1. Add conflictedFiles to the grouping useMemo in ChangesView
2. Render a "Merge Conflicts" section as the FIRST section
with AlertTriangle icon, red styling
3. Pass hasConflicts to GitCommitForm
4. Disable commit button when conflicts exist
5. Show helper text explaining why commit is disabled
A. Update file grouping in ChangesView.tsx (lines 88-110) to capture conflicted files:
const { stagedFiles, modifiedFiles, untrackedFiles, conflictedFiles, allFiles } = useMemo(() => {
const files = worktreePath ? fileStatusesByWorktree.get(worktreePath) || [] : []
const staged: GitFileStatus[] = []
const modified: GitFileStatus[] = []
const untracked: GitFileStatus[] = []
const conflicted: GitFileStatus[] = []
for (const file of files) {
if (file.status === 'C') {
conflicted.push(file)
} else if (file.staged) {
staged.push(file)
} else if (file.status === '?') {
untracked.push(file)
} else if (file.status === 'M' || file.status === 'D' || file.status === 'A') {
modified.push(file)
}
}
return {
stagedFiles: staged,
modifiedFiles: modified,
untrackedFiles: untracked,
conflictedFiles: conflicted,
allFiles: files
}
}, [worktreePath, fileStatusesByWorktree])B. Render "Merge Conflicts" section as the first collapsible section in ChangesView, before "Staged Changes":
{
conflictedFiles.length > 0 && (
<CollapsibleSection
title={`Merge Conflicts (${conflictedFiles.length})`}
icon={<AlertTriangle className="h-3.5 w-3.5 text-red-500" />}
defaultOpen
className="border-red-500/20"
>
{conflictedFiles.map((file) => (
<FileListItem key={file.path} file={file} onClick={() => handleViewDiff(file)} />
))}
</CollapsibleSection>
)
}C. Update GitCommitForm.tsx to accept and check for conflicts:
interface GitCommitFormProps {
worktreePath: string
hasConflicts?: boolean
}
// Update canCommit:
const canCommit = hasStaged && hasSummary && !isCommitting && !hasConflictsAdd a helper message below the commit button when conflicts exist:
{
hasConflicts && (
<p className="text-xs text-red-400 mt-1">Resolve merge conflicts before committing</p>
)
}D. Pass hasConflicts from ChangesView to GitCommitForm:
{
hasStaged && (
<GitCommitForm worktreePath={worktreePath} hasConflicts={conflictedFiles.length > 0} />
)
}| File | Change |
|---|---|
src/renderer/src/components/file-tree/ChangesView.tsx |
Add conflictedFiles to grouping, render "Merge Conflicts" section |
src/renderer/src/components/git/GitCommitForm.tsx |
Accept hasConflicts prop, disable commit, show helper text |
The merge branch dropdown in GitPushPull.tsx (line 46) uses local component state: useState(''). This means the merge target resets to empty every time the component remounts. There is no cross-worktree awareness -- committing on branch feature-x does not influence what other worktrees see in their merge dropdown.
Worktrees within a project are siblings: they share the same git repository and are tracked in useWorktreeStore.worktreesByProject: Map<string, Worktree[]> (line 26). Each worktree has a branch_name and path.
After a commit, useGitStore.commit() (line 342-357) calls refreshStatuses() but does not update any cross-worktree state.
Cross-worktree merge default:
Scenario:
- Project "myapp" has worktrees:
- main (default)
- feature-auth (branch: feature-auth)
- feature-ui (branch: feature-ui)
User commits on feature-auth:
┌──────────────────────────────────────────────────┐
│ After commit on feature-auth: │
│ │
│ On "main" worktree: │
│ Merge dropdown defaults to → "feature-auth" │
│ │
│ On "feature-ui" worktree: │
│ Merge dropdown defaults to → "feature-auth" │
│ │
│ On "feature-auth" worktree: │
│ Merge dropdown unchanged (own branch excluded) │
└──────────────────────────────────────────────────┘
Storage:
- useGitStore gets a new field:
defaultMergeBranch: Map<string, string>
(keyed by projectId → branch name of last commit)
- Set when commit() succeeds, using the branch name
from the worktree that committed
- Read by GitPushPull to initialize mergeBranch state
(only if the default branch isn't the current branch)
- In-memory only (no persistence needed -- resets on
app restart which is fine)
A. Add defaultMergeBranch to useGitStore:
interface GitStoreState {
// ... existing fields
defaultMergeBranch: Map<string, string> // projectId → branch name
setDefaultMergeBranch: (projectId: string, branchName: string) => void
}B. Set default merge branch after successful commit. Update commit() in useGitStore to set the merge default:
commit: async (worktreePath: string, message: string) => {
set({ isCommitting: true, error: null })
try {
const result = await window.gitOps.commit(worktreePath, message)
if (result.success) {
await get().refreshStatuses(worktreePath)
// Set this branch as the default merge target for sibling worktrees
const branchInfo = get().branchInfoByWorktree.get(worktreePath)
if (branchInfo?.name) {
// Find the project ID for this worktree path
const worktreeStore = useWorktreeStore.getState()
const worktree = worktreeStore.worktrees.find((w) => w.path === worktreePath)
if (worktree?.project_id) {
get().setDefaultMergeBranch(worktree.project_id, branchInfo.name)
}
}
}
set({ isCommitting: false })
return result
} catch (error) {
// ... existing error handling
}
}C. Read default merge branch in GitPushPull.tsx:
// Get the project-wide default merge branch
const selectedWorktree = useWorktreeStore((state) =>
state.worktrees.find((w) => w.path === worktreePath)
)
const defaultMergeBranch = useGitStore((state) =>
selectedWorktree?.project_id
? state.defaultMergeBranch.get(selectedWorktree.project_id)
: undefined
)
const branchInfo = useGitStore((state) => state.branchInfoByWorktree.get(worktreePath))
// Initialize merge branch from default (if it's not the current branch)
useEffect(() => {
if (defaultMergeBranch && defaultMergeBranch !== branchInfo?.name && !mergeBranch) {
setMergeBranch(defaultMergeBranch)
}
}, [defaultMergeBranch, branchInfo?.name])| File | Change |
|---|---|
src/renderer/src/stores/useGitStore.ts |
Add defaultMergeBranch map, setDefaultMergeBranch action, set on commit |
src/renderer/src/components/git/GitPushPull.tsx |
Read default merge branch, initialize dropdown state |
The PriorityBadge component in TodoWriteToolView.tsx (lines 31-43) renders priority as a text label in a colored pill:
<span
className={cn(
'text-[10px] rounded px-1.5 py-0.5 font-medium shrink-0 leading-none',
priority === 'high' && 'bg-red-500/15 text-red-500 dark:text-red-400',
priority === 'medium' && 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
priority === 'low' && 'bg-muted text-muted-foreground'
)}
>
{priority}
</span>This shows the word "high", "medium", or "low" as text. It takes up horizontal space and is less scannable than icons.
Jira-style chevron priority icons:
Low: ↓ (single down chevron, blue)
Medium: ↑ (single up chevron, yellow/amber)
High: ⇈ (double up chevron, red)
Icon mapping:
- Low: ChevronDown from lucide-react, text-blue-500
- Medium: ChevronUp from lucide-react, text-amber-500
- High: ChevronsUp from lucide-react, text-red-500
The icons replace the text entirely. No background pill
needed -- just the icon with color. The icon is placed
in the same position where the text badge currently is
(right side of each todo item).
Visual:
┌──────────────────────────────────────────────────┐
│ ○ Research existing metrics ↓ (blue) │
│ ◉ Implement core tracking ↑ (amber) │
│ ○ Fix critical bug ⇈ (red) │
│ ✓ Write tests ↓ (blue) │
└──────────────────────────────────────────────────┘
A. Replace PriorityBadge content in TodoWriteToolView.tsx:
import { ChevronDown, ChevronUp, ChevronsUp } from 'lucide-react'
function PriorityBadge({ priority }: { priority: TodoItem['priority'] }) {
switch (priority) {
case 'high':
return <ChevronsUp className="h-3.5 w-3.5 text-red-500 shrink-0" />
case 'medium':
return <ChevronUp className="h-3.5 w-3.5 text-amber-500 shrink-0" />
case 'low':
return <ChevronDown className="h-3.5 w-3.5 text-blue-500 shrink-0" />
default:
return null
}
}No other files need changes -- the PriorityBadge is only rendered within TodoWriteToolView.tsx (line 111).
| File | Change |
|---|---|
src/renderer/src/components/sessions/tools/TodoWriteToolView.tsx |
Replace PriorityBadge text with chevron icons |
Per-session model was implemented in Phase 17. New sessions inherit their model from the last session in the same worktree (useSessionStore.ts, lines 165-176), falling back to the global useSettingsStore.selectedModel.
However, this is stored per-session. The request is to persist the last-used model per worktree so that:
- When the user manually changes the model on any tab in a worktree, that becomes the worktree's default
- New tabs in that worktree default to the worktree's model
- This persists across app restarts (database-backed)
Currently, useSessionStore.createSession (line 165-176) looks at the last session's model columns. This mostly works, but:
- If the user changes models on an older session, the "last session" heuristic may not pick it up
- The intent is clearer with an explicit per-worktree default
The worktrees table does NOT have model columns. The sessions table has model_provider_id, model_id, model_variant (added in Phase 17 migration v11).
Per-worktree model persistence:
┌─────────────────────────────────────────────────────┐
│ worktrees table │
│ │
│ last_model_provider_id TEXT │
│ last_model_id TEXT │
│ last_model_variant TEXT │
└─────────────────────────────────────────────────────┘
When user manually changes model on any session tab:
1. Update the session's model (existing behavior)
2. Also update the worktree's last_model_* columns (NEW)
3. Persist to database
When creating a new session:
1. Check worktree's last_model_* columns
2. If set → use as default for the new session
3. If not → fall back to global selectedModel
4. Skip the "find last session's model" heuristic
Data flow:
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ ModelSelector │───▶│ useSessionStore │───▶│ sessions DB │
│ (per tab) │ │ .setSessionModel │ │ model columns│
│ │ │ │ │ │
│ │ │ Also calls: │ │ │
│ │ │ worktreeOps │───▶│ worktrees DB │
│ │ │ .updateModel() │ │ last_model_* │
└──────────────┘ └──────────────────┘ └──────────────┘
A. Database migration -- add model columns to worktrees table:
ALTER TABLE worktrees ADD COLUMN last_model_provider_id TEXT;
ALTER TABLE worktrees ADD COLUMN last_model_id TEXT;
ALTER TABLE worktrees ADD COLUMN last_model_variant TEXT;Bump CURRENT_SCHEMA_VERSION and add to MIGRATIONS array.
B. Add updateWorktreeModel IPC endpoint:
ipcMain.handle(
'db:worktree:updateModel',
async (_event, { worktreeId, modelProviderId, modelId, modelVariant }) => {
db.prepare(
`
UPDATE worktrees
SET last_model_provider_id = ?, last_model_id = ?, last_model_variant = ?
WHERE id = ?
`
).run(modelProviderId, modelId, modelVariant ?? null, worktreeId)
return { success: true }
}
)C. Expose in preload and type declarations.
D. Update setSessionModel in useSessionStore.ts to also persist to the worktree:
setSessionModel: async (sessionId: string, model: SelectedModel) => {
// ... existing session update logic ...
// Also persist as the worktree's last-used model
const session = get().sessions.get(sessionId)
if (session?.worktree_id) {
try {
await window.db.worktree.updateModel({
worktreeId: session.worktree_id,
modelProviderId: model.providerID,
modelId: model.modelID,
modelVariant: model.variant ?? null
})
// Update in-memory worktree record
useWorktreeStore.getState().updateWorktreeModel(session.worktree_id, model)
} catch {
/* non-critical */
}
}
}E. Update createSession default cascade to check worktree model first:
createSession: async (worktreeId: string, projectId: string) => {
// Priority 1: worktree's last-used model
const worktree = useWorktreeStore.getState().worktrees.find((w) => w.id === worktreeId)
const worktreeModel = worktree?.last_model_id
? {
model_provider_id: worktree.last_model_provider_id,
model_id: worktree.last_model_id,
model_variant: worktree.last_model_variant
}
: null
// Priority 2: global default
const globalModel = !worktreeModel
? (() => {
const global = useSettingsStore.getState().selectedModel
return global
? {
model_provider_id: global.providerID,
model_id: global.modelID,
model_variant: global.variant ?? null
}
: null
})()
: null
const defaultModel = worktreeModel || globalModel
const session = await window.db.session.create({
worktree_id: worktreeId,
project_id: projectId,
name: `New session - ${new Date().toISOString()}`,
...(defaultModel && {
model_provider_id: defaultModel.model_provider_id,
model_id: defaultModel.model_id,
model_variant: defaultModel.model_variant
})
})
// ...
}F. Add model fields to Worktree interface in src/preload/index.d.ts and useWorktreeStore.ts:
interface Worktree {
// ... existing fields
last_model_provider_id: string | null
last_model_id: string | null
last_model_variant: string | null
}| File | Change |
|---|---|
src/main/db/schema.ts |
Add migration: last_model_* columns on worktrees |
src/main/db/database.ts |
Add updateWorktreeModel method |
src/main/db/types.ts |
Add model fields to Worktree and WorktreeUpdate types |
src/main/ipc/database-handlers.ts |
Add db:worktree:updateModel IPC handler |
src/preload/index.ts |
Expose updateModel in db.worktree namespace |
src/preload/index.d.ts |
Add model fields to Worktree interface, type for updateModel |
src/renderer/src/stores/useWorktreeStore.ts |
Add updateWorktreeModel action, update Worktree interface |
src/renderer/src/stores/useSessionStore.ts |
Update setSessionModel to persist to worktree, update createSession default cascade |
SessionTabs.tsx (line 273) renders three tab types: SessionTab (line 38-174), FileTab (line 184-219), and DiffTabItem (line 229-271). None of them have a context menu. The only close mechanism is the X button and middle-click.
There are no "Close Others", "Close Others to the Right" actions anywhere in the codebase. The useSessionStore has closeSession(sessionId) (lines 234-300) for individual close. The useFileViewerStore has closeFile(path) (line 59-73) and closeAllFiles() (line 79-81).
The app already uses shadcn/ui ContextMenu in several places (WorktreeItem, SpacesTabBar, FileTreeNode, etc.), so the pattern is well-established.
Tab context menus:
Session tab (OpenCode) right-click:
┌──────────────────────────────┐
│ Close ⌘W │
│ Close Others │
│ Close Others to the Right │
└──────────────────────────────┘
File tab right-click:
┌──────────────────────────────┐
│ Close ⌘W │
│ Close Others │
│ Close Others to the Right │
│ ─────────────────────────────│
│ Copy Relative Path │
│ Copy Absolute Path │
└──────────────────────────────┘
Diff tab right-click:
┌──────────────────────────────┐
│ Close ⌘W │
│ Close Others │
│ Close Others to the Right │
│ ─────────────────────────────│
│ Copy Relative Path │
│ Copy Absolute Path │
└──────────────────────────────┘
"Close Others" closes all tabs of the same type
except the right-clicked one.
"Close Others to the Right" closes all tabs of the
same type that appear after the right-clicked tab
in the tab order.
Copy paths use the file path from the tab data.
For session tabs, there is no file path, so no
copy path options.
A. Add bulk close actions to useSessionStore:
closeOtherSessions: (worktreeId: string, keepSessionId: string) => {
const tabOrder = get().tabOrderByWorktree.get(worktreeId) || []
for (const sessionId of tabOrder) {
if (sessionId !== keepSessionId) {
get().closeSession(sessionId)
}
}
}
closeSessionsToRight: (worktreeId: string, fromSessionId: string) => {
const tabOrder = get().tabOrderByWorktree.get(worktreeId) || []
const index = tabOrder.indexOf(fromSessionId)
if (index === -1) return
const toClose = tabOrder.slice(index + 1)
for (const sessionId of toClose) {
get().closeSession(sessionId)
}
}B. Add bulk close actions to useFileViewerStore:
closeOtherFiles: (keepKey: string) => {
set((state) => {
const newMap = new Map()
const kept = state.openFiles.get(keepKey)
if (kept) newMap.set(keepKey, kept)
return {
openFiles: newMap,
activeFilePath: kept ? keepKey : null,
activeDiff: null
}
})
}
closeFilesToRight: (fromKey: string) => {
set((state) => {
const keys = [...state.openFiles.keys()]
const index = keys.indexOf(fromKey)
if (index === -1) return state
const newMap = new Map()
for (let i = 0; i <= index; i++) {
const entry = state.openFiles.get(keys[i])
if (entry) newMap.set(keys[i], entry)
}
return { openFiles: newMap }
})
}C. Wrap tab components with ContextMenu in SessionTabs.tsx:
For session tabs:
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="..."> {/* existing tab content */} </div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => closeSession(sessionId)}>
Close
<ContextMenuShortcut>⌘W</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => closeOtherSessions(worktreeId, sessionId)}>
Close Others
</ContextMenuItem>
<ContextMenuItem onClick={() => closeSessionsToRight(worktreeId, sessionId)}>
Close Others to the Right
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>For file/diff tabs, add the separator and copy path items:
<ContextMenuSeparator />
<ContextMenuItem onClick={() => copyToClipboard(relativePath)}>
Copy Relative Path
</ContextMenuItem>
<ContextMenuItem onClick={() => copyToClipboard(absolutePath)}>
Copy Absolute Path
</ContextMenuItem>D. Clipboard copy utility:
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
toast.success('Copied to clipboard')
}For relative path: strip the worktree path prefix from the absolute file path. For absolute path: use the full file path directly.
| File | Change |
|---|---|
src/renderer/src/stores/useSessionStore.ts |
Add closeOtherSessions, closeSessionsToRight actions |
src/renderer/src/stores/useFileViewerStore.ts |
Add closeOtherFiles, closeFilesToRight actions |
src/renderer/src/components/sessions/SessionTabs.tsx |
Wrap tabs with ContextMenu, add close/copy-path items |
| # | Feature | Complexity | Files |
|---|---|---|---|
| 1 | Dog breed names | Low | 9 files (rename + update refs) |
| 2 | Merge conflicts in sidebar | Medium | 2 files |
| 3 | Cross-worktree merge default | Medium | 2 files |
| 4 | Todo chevron icons | Low | 1 file |
| 5 | Per-worktree model persistence | High | 8 files (DB + IPC + stores) |
| 6 | Tab context menus | Medium | 3 files |