Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with a single group has no header and is unchanged. A pinned session still
carries its group to the top, which is the one order a drag cannot beat.

- **A worktree group's header spells the whole worktree name.** Worktrees named
the way most repositories name branches — `feat/x`, `fix/x` — came out of the
divider as `x`, because the header showed only the last folder of the
checkout's path. Two worktrees off the same ticket therefore drew the identical
header, and the divider stopped telling you which sessions were whose. The
header now reads the name the worktree was created with, slashes and all. A
worktree without a slash in it looks exactly as it did.

- **lich asks which agents you use, instead of assuming Claude Code.** The first
launch opened on a Claude session because Claude was the only harness lich's
author ran: a machine with Codex and nothing else met a first session that died
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/components/sidebar/SessionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
import { CSS } from "@dnd-kit/utilities"
import { useSortableList, verticalAxis } from "@/lib/use-sortable-list"
import { cn } from "@/lib/utils"
import { baseName } from "@/lib/paths"
import { checkoutLabel } from "@/lib/git/checkout-label"
import { groupKey, type Session } from "@/lib/session/sessions"
import { useProjects } from "@/providers/projects"
import { SessionCard } from "./SessionCard"
Expand Down Expand Up @@ -40,12 +40,12 @@ interface SessionGroupProps {
}

// SessionGroup renders one worktree's sessions under a static divider titled
// with the worktree folder name; the branch stays on each card. The title reads
// the group's own checkout path, never a session's live cwd, so a `cd` deeper
// into the tree never re-buckets the group. The isolated DndContext is what
// confines a card drag to reordering within the group; the group itself is a
// sortable of the sidebar's outer context, dragged by its header alone so the
// two never contend for the same pointer.
// with the worktree's name; the branch stays on each card. Both the title and
// the bucketing read the group's own checkout path, never a session's live cwd,
// so a `cd` deeper into the tree never re-buckets the group or renames it. The
// isolated DndContext is what confines a card drag to reordering within the
// group; the group itself is a sortable of the sidebar's outer context, dragged
// by its header alone so the two never contend for the same pointer.
//
// The card actions needing nothing but a session id — select, rename, open a
// terminal beside it — are wired here rather than threaded down from the
Expand All @@ -67,7 +67,7 @@ export function SessionGroup({
const navigate = useNavigate()
const ids = sessions.map((session) => session.id)
const { sensors, onDragEnd } = useSortableList(ids, onReorder)
const name = baseName(path || projectPath)
const name = checkoutLabel(path, projectPath, projectId)
const group = useSortable({ id: groupKey(path), disabled: !showHeader })
// The PR card keys off the group's real checkout — the project root for the
// root group (empty path), else the worktree — so a root project on a feature
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/lib/git/checkout-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { checkoutLabel } from "./checkout-label"

const PROJECT = "/home/me/code/lich"
const ID = "e48f04f46a4a"
const ROOT = `/home/me/.local/share/lich/worktrees/${ID}`

describe("checkoutLabel", () => {
it("titles the root group with the project folder", () => {
expect(checkoutLabel("", PROJECT, ID)).toBe("lich")
})

it("keeps the whole worktree name when it holds a slash", () => {
expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).toBe("feat/x")
})

it("tells apart two worktrees sharing a last segment", () => {
expect(checkoutLabel(`${ROOT}/feat/x`, PROJECT, ID)).not.toBe(
checkoutLabel(`${ROOT}/fix/x`, PROJECT, ID),
)
})

it("names a slashless worktree the same as before", () => {
expect(checkoutLabel(`${ROOT}/eager-willow`, PROJECT, ID)).toBe("eager-willow")
})

it("reads a Windows checkout, which lich spells with backslashes", () => {
expect(
checkoutLabel(`C:\\Users\\me\\AppData\\lich\\worktrees\\${ID}\\feat\\x`, PROJECT, ID),
).toBe("feat/x")
})

it("cuts at the root, not at a worktree named after the project id", () => {
expect(checkoutLabel(`${ROOT}/${ID}/x`, PROJECT, ID)).toBe(`${ID}/x`)
})

it("falls back to the last segment for a checkout outside the worktree root", () => {
expect(checkoutLabel("/home/me/code/lich-elsewhere", PROJECT, ID)).toBe("lich-elsewhere")
})
})
36 changes: 36 additions & 0 deletions frontend/src/lib/git/checkout-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { baseName } from "@/lib/paths"

// checkoutLabel titles a sidebar group with the checkout it holds: the project's
// own folder for the root group (an empty path), else the worktree's name.
//
// A worktree's name is not its last path segment. It lives at
// <worktrees-root>/<projectID>/<name> and git accepts a slash in a branch name,
// so one created as "feat/x" sits at ".../feat/x" and its final segment is "x" —
// the same header "fix/x" would draw, leaving the divider naming neither
// checkout. The project id is the one part of that root the frontend knows, and
// it is enough: everything past it is the name the worktree was made with.
//
// The name is deliberately read off the directory rather than off the branch the
// checkout currently holds. They agree only at creation; an agent that branches
// inside a worktree — the ordinary way to work in one — moves the branch and
// leaves the directory, and a header that followed it would rename a group under
// the user and repeat what every card in it already says.
//
// A checkout outside that root (a worktree added by hand elsewhere, then opened
// here) has no name to read and keeps its last segment.
export function checkoutLabel(path: string, projectPath: string, projectId: string): string {
if (!path) {
return baseName(projectPath)
}
// git reports a Windows checkout with forward slashes while lich builds one
// with backslashes; the same path reaches this in either spelling.
const unix = path.replace(/\\/g, "/")
const root = `/${projectId}/`
// The first match, not the last: a worktree may itself be named after the
// project id, and the root is the one that comes first.
const at = unix.indexOf(root)
if (at === -1) {
return baseName(path)
}
return unix.slice(at + root.length)
}