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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Pin a session.** The session you keep coming back to no longer drifts down
the sidebar as new ones open above it: pin it from the card's right-click menu
or from the pin that appears beside the × on hover, and it sorts to the top of
the list and stays there across restarts. A pinned card also drops its close
affordances — no ×, no **Close session** in the menu, and the worktree it lives
in refuses to be removed — so the session you meant to keep cannot be closed by
a stray click. Unpin it and everything comes back, including the slot in the
list it had before.

- **The pull request's description is editable in the app, and its reviewers are
picked there too.** The Overview tab was a read-only rendering of the body: a
description an agent wrote and you wanted to trim, and choosing who reviews,
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/pulls/Pulls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,20 @@ export function Pulls({ list = false }: PullsProps) {
if (!projectId) {
return
}
const occupants = sessionsOf(sessions, projectId).filter((s) => s.path === wtPath)
// Removing the checkout would take its pinned sessions with it, which is
// exactly what the pin refuses.
if (occupants.some((s) => s.pinned)) {
toast.error("Worktree has a pinned session — unpin it first.")
return
}
if (await ProjectService.WorktreeDirty(wtPath).catch(() => false)) {
toast.error("Worktree has uncommitted changes — remove it from the sidebar.")
return
}
// The PTYs living in the checkout must die before git pulls the directory
// out from under them, and no parked row may survive to offer a resume.
for (const session of sessionsOf(sessions, projectId).filter((s) => s.path === wtPath)) {
for (const session of occupants) {
closeSession(projectId, session.id)
}
void Store.PurgeWorktreeSessions(projectId, wtPath)
Expand Down
73 changes: 57 additions & 16 deletions frontend/src/components/sidebar/SessionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"
import type { KeyboardEvent } from "react"
import { GitBranch, GitPullRequestArrow, Pencil, Terminal, X } from "lucide-react"
import { GitBranch, GitPullRequestArrow, Pencil, Pin, PinOff, Terminal, X } from "lucide-react"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { cn } from "@/lib/utils"
Expand Down Expand Up @@ -30,6 +30,9 @@ interface SessionCardProps {
onSelect: () => void
onClose: () => void
onRename: (label: string) => void
// Pin the card to the head of the list, or unpin it. A pinned card offers no
// close affordance at all — unpinning is the way back to closing it.
onPin: (pinned: boolean) => void
// Open a shell session rooted at this card's shown directory. Wired only for
// agent sessions, so the user can drop into a terminal in the worktree the
// agent is working in without cd-ing there by hand.
Expand All @@ -46,9 +49,11 @@ export function SessionCard({
onSelect,
onClose,
onRename,
onPin,
onOpenTerminal,
onPulls,
}: SessionCardProps) {
const pinned = !!session.pinned
const pathRef = useRef<HTMLSpanElement>(null)
const [pathOverflow, setPathOverflow] = useState(false)
const [editing, setEditing] = useState(false)
Expand Down Expand Up @@ -145,10 +150,18 @@ export function SessionCard({
onClick={(event) => event.stopPropagation()}
onKeyDown={onEditKeyDown}
onBlur={(event) => commit(event.currentTarget.value)}
className="w-full rounded-sm bg-transparent pr-5 text-sm font-medium text-foreground outline-none ring-1 ring-accent-foreground/30"
className={cn(
"w-full rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-accent-foreground/30",
pinned ? "pr-6" : "pr-11",
)}
/>
) : (
<span className="flex w-full min-w-0 items-center gap-1.5 pr-5">
<span
className={cn(
"flex w-full min-w-0 items-center gap-1.5",
pinned ? "pr-6" : "pr-11",
)}
>
<SessionStatusIcon kind={agent ?? session.kind} status={status} />
<span className="truncate text-sm font-medium text-foreground">
{session.label}
Expand Down Expand Up @@ -194,14 +207,34 @@ export function SessionCard({
</span>
)}
</div>
<CloseButton
label={`Close ${session.label}`}
onClick={(event) => {
event.stopPropagation()
onClose()
}}
className="absolute right-2 top-2"
/>
{/* A pinned card keeps its pin on screen — it is both the state's
only mark and the way to undo it — and shows no × at all: closing
is what the pin withholds. */}
<span className="absolute right-2 top-2 flex items-center gap-1">
<span
role="button"
aria-label={pinned ? `Unpin ${session.label}` : `Pin ${session.label}`}
onClick={(event) => {
event.stopPropagation()
onPin(!pinned)
}}
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded transition-opacity hover:bg-foreground/15",
pinned ? "text-foreground" : "opacity-0 group-hover:opacity-100",
)}
>
<Pin className={cn("size-3", pinned && "fill-current")} />
</span>
{!pinned && (
<CloseButton
label={`Close ${session.label}`}
onClick={(event) => {
event.stopPropagation()
onClose()
}}
/>
)}
</span>
</ContextMenuTrigger>
<TooltipContent
side="right"
Expand Down Expand Up @@ -236,6 +269,10 @@ export function SessionCard({
<Pencil />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onPin(!pinned)}>
{pinned ? <PinOff /> : <Pin />}
{pinned ? "Unpin" : "Pin"}
</ContextMenuItem>
{session.kind !== "shell" && (
<ContextMenuItem onClick={() => onOpenTerminal(shownPath)}>
<Terminal />
Expand All @@ -246,11 +283,15 @@ export function SessionCard({
<GitPullRequestArrow />
Pull request
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={onClose}>
<X />
Close session
</ContextMenuItem>
{!pinned && (
<>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={onClose}>
<X />
Close session
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/sidebar/SessionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function SessionGroup({
onPulls,
onClosePulls,
}: SessionGroupProps) {
const { activateSession, renameSession, newSession } = useProjects()
const { activateSession, renameSession, pinSession, newSession } = useProjects()
const navigate = useNavigate()
const ids = sessions.map((session) => session.id)
const { sensors, onDragEnd } = useSortableList(ids, onReorder)
Expand Down Expand Up @@ -102,6 +102,7 @@ export function SessionGroup({
onSelect={() => select(session.id)}
onClose={() => onClose(session)}
onRename={(label) => renameSession(projectId, session.id, label)}
onPin={(pinned) => pinSession(projectId, session.id, pinned)}
onOpenTerminal={(cwd) => newSession(projectId, "shell", cwd)}
onPulls={onPulls}
/>
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/sidebar/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { useProjects } from "@/providers/projects"
import { queueSetup } from "@/lib/terminal/setup-queue"
import { activeSessionId, groupByWorktree, sessionsOf } from "@/lib/session/sessions"
import { activeSessionId, groupByWorktree, sessionsOf, sortPinned } from "@/lib/session/sessions"
import { CloseWorktreeDialog, ForceRemoveWorktreeDialog } from "./CloseWorktreeDialog"
import { SessionGroup } from "./SessionGroup"
import { WorktreeDialog } from "./WorktreeDialog"
Expand Down Expand Up @@ -86,7 +86,9 @@ export function SessionSidebar() {
})
const [worktreeOpen, setWorktreeOpen] = useState(false)
// Resolved ahead of the no-project bail below: hooks cannot sit behind it.
const list = sessionsOf(sessions, projectId ?? "")
// Pinned first, which also carries their worktree group to the top of the
// sidebar — groupByWorktree buckets by first appearance.
const list = sortPinned(sessionsOf(sessions, projectId ?? ""))
const worktreeClose = useWorktreeClose(projectId ?? "", path, list)

if (!projectId) {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/sidebar/useWorktreeClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export function useWorktreeClose(
pendingForce,

requestClose(session) {
// The card hides both close affordances while pinned; this is the contract
// behind them, so a keep-or-remove dialog can never open on a pinned
// session either.
if (session.pinned) {
return
}
if (isLastWorktreeSession(sessions, session)) {
setPendingClose(session)
return
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export interface StoredSession {
kind: string
path: string
providerSessionId: string
pinned: boolean
}

/** internal/store.Project — a persisted project with its session state. */
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ export const Store = {
call<null>("store.PurgeWorktreeSessions", [projectID, path]),
RenameSession: (sessionID: string, label: string) =>
call<null>("store.RenameSession", [sessionID, label]),
/** Pin (or unpin) a session: it sorts to the head of its project's list and
* refuses to close until unpinned. */
SetSessionPinned: (sessionID: string, pinned: boolean) =>
call<null>("store.SetSessionPinned", [sessionID, pinned]),
SetActiveSession: (projectID: string, sessionID: string) =>
call<null>("store.SetActiveSession", [projectID, sessionID]),
ReorderProjects: (ids: string[]) => call<null>("store.ReorderProjects", [ids]),
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/lib/session/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
resumableSession,
sessionsOf,
setActiveSession,
setSessionPinned,
sortPinned,
type Session,
type SessionState,
} from "./sessions"
Expand Down Expand Up @@ -257,6 +259,61 @@ describe("reorderSessions", () => {
})
})

describe("sortPinned", () => {
it("hoists the pinned sessions, keeping both blocks in order", () => {
let state = setSessionPinned(buildState(4), P, "s3", true)
state = setSessionPinned(state, P, "s1", true)
expect(sortPinned(sessionsOf(state, P)).map((s) => s.id)).toEqual(["s1", "s3", "s2", "s4"])
})

it("returns the list untouched when nothing is pinned", () => {
const list = sessionsOf(buildState(3), P)
expect(sortPinned(list)).toBe(list)
})

// The hoist is display-only, so unpinning drops a session back among the
// neighbours it was lifted over instead of stranding it on top.
it("puts an unpinned session back where the stored order has it", () => {
let state = setSessionPinned(buildState(3), P, "s3", true)
expect(sortPinned(sessionsOf(state, P)).map((s) => s.id)).toEqual(["s3", "s1", "s2"])
state = setSessionPinned(state, P, "s3", false)
expect(sortPinned(sessionsOf(state, P)).map((s) => s.id)).toEqual(["s1", "s2", "s3"])
})
})

describe("setSessionPinned", () => {
it("marks the session pinned without moving it", () => {
const next = setSessionPinned(buildState(3), P, "s3", true)
expect(sessionsOf(next, P).map((s) => s.id)).toEqual(["s1", "s2", "s3"])
expect(sessionsOf(next, P)[2].pinned).toBe(true)
})

it("unpins a pinned session", () => {
let state = setSessionPinned(buildState(3), P, "s3", true)
state = setSessionPinned(state, P, "s3", false)
expect(sessionsOf(state, P)[2].pinned).toBe(false)
})

it("leaves the active session and the label counter alone", () => {
const state = buildState(3)
const next = setSessionPinned(state, P, "s1", true)
expect(activeSessionId(next, P)).toBe(activeSessionId(state, P))
expect(next[P].nextSeq).toBe(state[P].nextSeq)
})

it("ignores unknown project and session ids", () => {
const state = buildState(2)
expect(setSessionPinned(state, "nope", "s1", true)).toBe(state)
expect(setSessionPinned(state, P, "ghost", true)).toBe(state)
})

it("does not mutate the input state", () => {
const state = buildState(3)
setSessionPinned(state, P, "s3", true)
expect(sessionsOf(state, P)[2].pinned).toBeUndefined()
})
})

describe("resumableSession", () => {
it("returns a restored claude session carrying a claude session id", () => {
const state = withClaudeSession(buildState(2), "s1", "claude-abc")
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/lib/session/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface Session {
// set by the store: a session created in this run has none, and the hook's
// later report is not mirrored here — a running session has nothing to resume.
providerSessionId?: string
// Kept at the head of the project's list and refused a close until unpinned.
pinned?: boolean
}

export interface ProjectSessions {
Expand Down Expand Up @@ -177,6 +179,40 @@ export function renameSession(
}
}

// sortPinned hoists the pinned sessions to the head of the list. The partition
// is stable, so both blocks keep the order the drags gave them.
//
// It sorts for display and never writes the result back into the state: the
// stored list stays in drag order, which is what lets an unpinned session drop
// back among its old neighbours instead of being stranded on top. The store
// hands back that same drag order, so a reload draws what the pin did live.
export function sortPinned(sessions: Session[]): Session[] {
const pinned = sessions.filter((s) => s.pinned)
return pinned.length === 0 ? sessions : [...pinned, ...sessions.filter((s) => !s.pinned)]
}

// setSessionPinned pins or unpins a session, leaving the list order alone —
// sortPinned is what hoists it, at render time. Unknown project or session ids
// are ignored, returning the input state unchanged.
export function setSessionPinned(
state: SessionState,
projectId: string,
sessionId: string,
pinned: boolean,
): SessionState {
const current = state[projectId]
if (!current || !current.sessions.some((s) => s.id === sessionId)) {
return state
}
return {
...state,
[projectId]: {
...current,
sessions: current.sessions.map((s) => (s.id === sessionId ? { ...s, pinned } : s)),
},
}
}

// reorderSessions rearranges a project's sessions to match the given id order,
// leaving the active session and the label counter alone — a drag only moves
// cards. An id list that no longer names the project's exact session set (a
Expand Down
Loading