diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f58ae5d..4b48b229f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/frontend/src/components/pulls/Pulls.tsx b/frontend/src/components/pulls/Pulls.tsx index 5b4de269e..5aa418f43 100644 --- a/frontend/src/components/pulls/Pulls.tsx +++ b/frontend/src/components/pulls/Pulls.tsx @@ -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) diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 98b541046..7cec9a3a5 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -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" @@ -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. @@ -46,9 +49,11 @@ export function SessionCard({ onSelect, onClose, onRename, + onPin, onOpenTerminal, onPulls, }: SessionCardProps) { + const pinned = !!session.pinned const pathRef = useRef(null) const [pathOverflow, setPathOverflow] = useState(false) const [editing, setEditing] = useState(false) @@ -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", + )} /> ) : ( - + {session.label} @@ -194,14 +207,34 @@ export function SessionCard({ )} - { - 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. */} + + { + 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", + )} + > + + + {!pinned && ( + { + event.stopPropagation() + onClose() + }} + /> + )} + Rename + onPin(!pinned)}> + {pinned ? : } + {pinned ? "Unpin" : "Pin"} + {session.kind !== "shell" && ( onOpenTerminal(shownPath)}> @@ -246,11 +283,15 @@ export function SessionCard({ Pull request - - - - Close session - + {!pinned && ( + <> + + + + Close session + + + )} diff --git a/frontend/src/components/sidebar/SessionGroup.tsx b/frontend/src/components/sidebar/SessionGroup.tsx index eba788930..e33b977dd 100644 --- a/frontend/src/components/sidebar/SessionGroup.tsx +++ b/frontend/src/components/sidebar/SessionGroup.tsx @@ -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) @@ -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} /> diff --git a/frontend/src/components/sidebar/SessionSidebar.tsx b/frontend/src/components/sidebar/SessionSidebar.tsx index 400563e81..3d41ce901 100644 --- a/frontend/src/components/sidebar/SessionSidebar.tsx +++ b/frontend/src/components/sidebar/SessionSidebar.tsx @@ -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" @@ -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) { diff --git a/frontend/src/components/sidebar/useWorktreeClose.ts b/frontend/src/components/sidebar/useWorktreeClose.ts index 3993d19d5..10de36f4f 100644 --- a/frontend/src/components/sidebar/useWorktreeClose.ts +++ b/frontend/src/components/sidebar/useWorktreeClose.ts @@ -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 diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 4068c1907..8f1688abc 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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. */ diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 01be0518e..8c791f3ad 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -277,6 +277,10 @@ export const Store = { call("store.PurgeWorktreeSessions", [projectID, path]), RenameSession: (sessionID: string, label: string) => call("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("store.SetSessionPinned", [sessionID, pinned]), SetActiveSession: (projectID: string, sessionID: string) => call("store.SetActiveSession", [projectID, sessionID]), ReorderProjects: (ids: string[]) => call("store.ReorderProjects", [ids]), diff --git a/frontend/src/lib/session/sessions.test.ts b/frontend/src/lib/session/sessions.test.ts index 8d9286c35..b3f5aba9b 100644 --- a/frontend/src/lib/session/sessions.test.ts +++ b/frontend/src/lib/session/sessions.test.ts @@ -15,6 +15,8 @@ import { resumableSession, sessionsOf, setActiveSession, + setSessionPinned, + sortPinned, type Session, type SessionState, } from "./sessions" @@ -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") diff --git a/frontend/src/lib/session/sessions.ts b/frontend/src/lib/session/sessions.ts index b0b7fd257..572322c9f 100644 --- a/frontend/src/lib/session/sessions.ts +++ b/frontend/src/lib/session/sessions.ts @@ -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 { @@ -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 diff --git a/frontend/src/providers/projects.tsx b/frontend/src/providers/projects.tsx index 96160b129..3cb78e65d 100644 --- a/frontend/src/providers/projects.tsx +++ b/frontend/src/providers/projects.tsx @@ -19,6 +19,7 @@ import { restoreSession, sessionsOf, setActiveSession, + setSessionPinned, type Session, type SessionKind, type SessionState, @@ -74,6 +75,9 @@ interface ProjectsValue { activateSession: (projectId: string, sessionId: string) => void /** Rename a session's display label. */ renameSession: (projectId: string, sessionId: string, label: string) => void + /** Pin a session to the head of its project's list, or unpin it. A pinned + * session refuses to close until it is unpinned. */ + pinSession: (projectId: string, sessionId: string, pinned: boolean) => void /** Rearrange the project tabs to the given id order (drag-and-drop). */ reorderProjects: (ids: string[]) => void /** Rearrange a project's session cards to the given id order (drag-and-drop). */ @@ -108,6 +112,7 @@ function buildSessionState(loaded: StoreProject[]): SessionState { kind: isSessionKind(s.kind) ? s.kind : "claude", ...(s.path ? { path: s.path } : {}), ...(s.providerSessionId ? { providerSessionId: s.providerSessionId } : {}), + ...(s.pinned ? { pinned: true } : {}), })) state[p.id] = { sessions, @@ -499,6 +504,15 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { void Store.RenameSession(sessionId, label) }, []) + const pinSession = useCallback((projectId: string, sessionId: string, pinned: boolean) => { + const next = setSessionPinned(sessionsRef.current, projectId, sessionId, pinned) + if (next === sessionsRef.current) { + return + } + setSessions(next) + void Store.SetSessionPinned(sessionId, pinned) + }, []) + const value = useMemo( () => ({ projects, @@ -515,6 +529,7 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { keepSession, activateSession, renameSession, + pinSession, reorderProjects, reorderSessions, }), @@ -533,6 +548,7 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { keepSession, activateSession, renameSession, + pinSession, reorderProjects, reorderSessions, ], diff --git a/internal/store/mutations.go b/internal/store/mutations.go index e7e68564a..e5fa18a72 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -220,6 +220,19 @@ func (s *Service) RenameSession(sessionID, label string) error { return nil } +// SetSessionPinned pins or unpins a session — the flag the sidebar reads to +// hoist a card to the head of the list, and to withhold its close affordances. +// It leaves position untouched, so an unpinned session falls back to the slot +// the last drag gave it rather than landing wherever the pinned block ended. +func (s *Service) SetSessionPinned(sessionID string, pinned bool) error { + if _, err := s.db.Exec( + `UPDATE sessions SET pinned = ? WHERE id = ?`, pinned, sessionID, + ); err != nil { + return fmt.Errorf("set session %q pinned: %w", sessionID, err) + } + return nil +} + // SetSessionTitle sets a session's label from the provider's ai-title reported // by the Stop hook, but only while the label is still automatic: a prior // RenameSession clears label_auto and makes this a no-op, so a user's own name diff --git a/internal/store/store.go b/internal/store/store.go index d3545d7de..1763df74f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS sessions ( provider_session_id TEXT NOT NULL DEFAULT '', label_auto INTEGER NOT NULL DEFAULT 1, is_open INTEGER NOT NULL DEFAULT 1, - position INTEGER NOT NULL DEFAULT 0 + position INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); @@ -78,13 +79,16 @@ type Service struct { // provider CLI assigns the conversation running in the PTY, reported by that // provider's session-start hook; empty until a hook fires (or for shell // sessions), it is the key for features that need to reach a session's -// transcript or resume it. Only Claude Code reports one today. +// transcript or resume it. Only Claude Code reports one today. Pinned keeps a +// session at the head of its project's list and withholds its close affordances +// until it is unpinned. type Session struct { ID string `json:"id"` Label string `json:"label"` Kind string `json:"kind"` Path string `json:"path"` ProviderSessionID string `json:"providerSessionId"` + Pinned bool `json:"pinned"` } // Project is a persisted project together with its restorable session state. @@ -145,6 +149,7 @@ func open(path string) (*Service, error) { `ALTER TABLE sessions ADD COLUMN label_auto INTEGER NOT NULL DEFAULT 1`, `ALTER TABLE sessions ADD COLUMN is_open INTEGER NOT NULL DEFAULT 1`, `ALTER TABLE sessions ADD COLUMN position INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sessions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE projects ADD COLUMN position INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE projects ADD COLUMN closed_seq INTEGER NOT NULL DEFAULT 0`, } @@ -269,9 +274,14 @@ func (s *Service) RecentProjects() ([]Recent, error) { // sessionsOf returns a project's sessions in the order the user dragged them // into, falling back to insertion order for rows never reordered. +// +// Pinned rows are not hoisted here, and pinning never rewrites position: this is +// the drag order, and the sidebar lifts the pinned cards over it when it draws. +// Hoisting in both places would cost a session its slot on every reload, since +// the frontend would have no order left to put an unpinned card back into. func (s *Service) sessionsOf(projectID string) ([]Session, error) { rows, err := s.db.Query( - `SELECT id, label, kind, path, provider_session_id + `SELECT id, label, kind, path, provider_session_id, pinned FROM sessions WHERE project_id = ? AND is_open = 1 ORDER BY position, rowid`, projectID, ) @@ -283,7 +293,9 @@ func (s *Service) sessionsOf(projectID string) ([]Session, error) { sessions := []Session{} for rows.Next() { var sess Session - if err := rows.Scan(&sess.ID, &sess.Label, &sess.Kind, &sess.Path, &sess.ProviderSessionID); err != nil { + if err := rows.Scan( + &sess.ID, &sess.Label, &sess.Kind, &sess.Path, &sess.ProviderSessionID, &sess.Pinned, + ); err != nil { return nil, fmt.Errorf("scan session: %w", err) } sessions = append(sessions, sess) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index f9c8175ac..30f57c63f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -508,6 +508,47 @@ func TestReorderSessionsIgnoresForeignSession(t *testing.T) { } } +// The pin is a flag on the row and nothing more: LoadState keeps handing back +// the drag order, which is the order an unpinned card falls back into. Hoisting +// here as well would erase that slot on every reload. +func TestSetSessionPinnedPersistsWithoutMovingTheRow(t *testing.T) { + svc := newTestStore(t) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "s1", "Session 1", "", "", 2) + _ = svc.AddSession("p1", "s2", "Session 2", "", "", 3) + _ = svc.AddSession("p1", "s3", "Session 3", "", "", 4) + _ = svc.ReorderSessions("p1", []string{"s2", "s3", "s1"}) + + if err := svc.SetSessionPinned("s1", true); err != nil { + t.Fatalf("SetSessionPinned: %v", err) + } + if got := sessionIDs(t, svc, "p1"); !equalIDs(got, []string{"s2", "s3", "s1"}) { + t.Errorf("order after pin = %v, want [s2 s3 s1]", got) + } + projects, err := svc.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + for _, s := range projects[0].Sessions { + if want := s.ID == "s1"; s.Pinned != want { + t.Errorf("session %q pinned = %v, want %v", s.ID, s.Pinned, want) + } + } + + if err := svc.SetSessionPinned("s1", false); err != nil { + t.Fatalf("unpin: %v", err) + } + projects, err = svc.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + for _, s := range projects[0].Sessions { + if s.Pinned { + t.Errorf("session %q still pinned", s.ID) + } + } +} + func TestReorderProjectsPersistsOrder(t *testing.T) { svc := newTestStore(t) _ = svc.AddProject("p1", "alpha", "/tmp/alpha")