From 3a5d04f1bebac6c63afdab2498df48f32627be47 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 7 Aug 2026 11:08:35 -0300 Subject: [PATCH] feat: pin a session to the top of the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session you keep returning to drifts down the list as new ones open above it, and the × sits one stray click away from ending it for good. Pinning answers both. A pinned card sorts to the head of its project's list and stays there across restarts, and it drops every close affordance it had: no × on hover, no "Close session" in the context menu, and the worktree it lives in refuses to be removed from the Pulls screen. Unpin and all of it comes back, including the slot in the list the card had before. The order has one owner. The store keeps the drag order and the pin flag and nothing else — `sortPinned` lifts the pinned cards over that order when the sidebar draws. Hoisting in the query too would cost a session its slot on every reload: the frontend would hydrate from an already hoisted list and have no order left to drop an unpinned card back into. Grouping falls out of the same list: groupByWorktree buckets by first appearance, so pinning a worktree's session carries its group to the top with it. The guard behind the hidden affordances is in requestClose, not in the provider's closeSession — removing a worktree closes its sessions through that same primitive, and a PTY that outlives `git worktree remove` takes the checkout down with it. Pulls refuses the removal up front instead, the way it already refuses a dirty checkout. --- CHANGELOG.md | 11 +++ frontend/src/components/pulls/Pulls.tsx | 9 ++- .../src/components/sidebar/SessionCard.tsx | 73 +++++++++++++++---- .../src/components/sidebar/SessionGroup.tsx | 3 +- .../src/components/sidebar/SessionSidebar.tsx | 6 +- .../components/sidebar/useWorktreeClose.ts | 6 ++ frontend/src/lib/api-types.ts | 1 + frontend/src/lib/rpc.ts | 4 + frontend/src/lib/session/sessions.test.ts | 57 +++++++++++++++ frontend/src/lib/session/sessions.ts | 36 +++++++++ frontend/src/providers/projects.tsx | 16 ++++ internal/store/mutations.go | 13 ++++ internal/store/store.go | 20 ++++- internal/store/store_test.go | 41 +++++++++++ 14 files changed, 272 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7815ad7c7..bd32db287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### Fixed - **Windows: the app list and the file picker wear the current icon.** The icon 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 b1bad2fb9..05920f3f2 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -238,6 +238,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 d50a0ff0b..b4ba1815f 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -266,6 +266,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")